@nextcommerce/campaigns-os 1.43.2 → 1.46.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/AGENTS.md +5 -0
- package/CHANGELOG.md +648 -5103
- package/README.md +32 -11
- package/agents/claude/CLAUDE.md +1 -1
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
- package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
- package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
- package/campaign-spec/dist/types.d.ts +2 -2
- package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
- package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
- package/contracts/effects.v1.json +1176 -113
- package/contracts/orientation-reason-codes.v1.json +7 -0
- package/contracts/release-ledger.json +2345 -6087
- package/contracts/supported-surface.json +7 -4
- package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
- package/docs/brand-theme-bridge.md +81 -0
- package/docs/build-packet.md +158 -21
- package/docs/campaigns-os-build-flow.md +3 -3
- package/docs/design-source-package.md +73 -0
- package/docs/effects.md +50 -8
- package/docs/gateway-login.md +3 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +42 -2
- package/docs/polish-evidence.md +74 -0
- package/docs/qa-and-test-orders.md +99 -13
- package/docs/release-ledger-authoring-guide.md +64 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -2
- package/docs/versioning.md +4 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +7 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +7 -6
- package/skills/next-campaigns-os/SKILL.md +7 -7
- package/skills/next-campaigns-os/references/session-intake.md +9 -3
- package/skills/next-campaigns-os-setup/SKILL.md +5 -5
- package/skills/next-campaigns-polish/SKILL.md +28 -9
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills.json +10 -10
- package/src/brand-theme.mjs +320 -20
- package/src/built-site-scope.mjs +16 -4
- package/src/cli.mjs +280 -46
- package/src/commercial-parity.mjs +48 -2
- package/src/deviation.mjs +13 -1
- package/src/diagnostic.mjs +5 -2
- package/src/doctor/checks.mjs +320 -81
- package/src/doctor/inspect.mjs +55 -13
- package/src/doctor/source-provenance.mjs +184 -0
- package/src/invocation.mjs +4 -0
- package/src/live-campaign-refs.mjs +466 -0
- package/src/login.mjs +2 -2
- package/src/page-kit-store-profile.mjs +69 -12
- package/src/page-kit-sync.mjs +31 -12
- package/src/progress-node.mjs +3 -1
- package/src/qa-browser.mjs +538 -28
- package/src/qa-commercial-parity.mjs +48 -5
- package/src/qa-node.mjs +122 -7
- package/src/qa-test-order-topology.mjs +148 -0
- package/src/sdk-markup.mjs +72 -8
- package/src/source-html-intake.mjs +116 -0
- package/src/stage-record.mjs +551 -0
- package/src/upsell-selector-scope.mjs +112 -2
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// `campaigns-os record <setup|build|polish>`: record a stage's completion on
|
|
2
|
+
// the Build Context and Assembly Report through one validated command instead
|
|
3
|
+
// of hand-edited JSON.
|
|
4
|
+
//
|
|
5
|
+
// Every value a record stamps is read from the doctor result the `next` ladder
|
|
6
|
+
// itself reads (doctorPacket over the same packet and sidecars), so a record
|
|
7
|
+
// can never carry a fingerprint doctor did not compute. The packet is read
|
|
8
|
+
// once to name the target lock; under the lock it is re-read and re-checked
|
|
9
|
+
// with the report binding, the report, the Build Context and doctor's result,
|
|
10
|
+
// and the record is composed on that report, validated against the existing
|
|
11
|
+
// schemas and doctor's own report checks, and refused whole (nothing written)
|
|
12
|
+
// when any of those fails. --dry-run takes the same path, without the lock,
|
|
13
|
+
// and writes nothing.
|
|
14
|
+
//
|
|
15
|
+
// The Build Context holds setup state only (`scaffold`); build and polish
|
|
16
|
+
// completion live on the Assembly Report alone, so `record build` and `record
|
|
17
|
+
// polish` validate the context they read but write only the report.
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
23
|
+
|
|
24
|
+
import { computeBuildFingerprint } from "./built-site-scope.mjs";
|
|
25
|
+
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
26
|
+
import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
27
|
+
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
28
|
+
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
29
|
+
import { validateAssemblyReport } from "./doctor/checks.mjs";
|
|
30
|
+
import { cmd } from "./install-invocation.mjs";
|
|
31
|
+
import { refused } from "./lifecycle.mjs";
|
|
32
|
+
import { NEXT_STAGE_ORDER, reportKeyForCliStage, stageIsTerminal } from "./orchestration-stage-contract.mjs";
|
|
33
|
+
import {
|
|
34
|
+
POLISH_GATE_REQUIRED_EVIDENCE,
|
|
35
|
+
POLISH_PRODUCER,
|
|
36
|
+
currentSourcePackageMaterialFingerprint,
|
|
37
|
+
evaluatePolishGate,
|
|
38
|
+
} from "./polish-gate.mjs";
|
|
39
|
+
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
40
|
+
import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
|
|
41
|
+
import { withTargetLockSync } from "./target-lock.mjs";
|
|
42
|
+
|
|
43
|
+
export const RECORD_STAGES = Object.freeze(["setup", "build", "polish"]);
|
|
44
|
+
|
|
45
|
+
// Every flag `record` reads, plus the two any command accepts (run id and
|
|
46
|
+
// lifecycle journal). Anything else is refused before a file is read.
|
|
47
|
+
const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal"]);
|
|
48
|
+
const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
|
|
49
|
+
|
|
50
|
+
// The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
|
|
51
|
+
// `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
|
|
52
|
+
// blocked) and `skip_reason` (status skipped) land on stages.polish.
|
|
53
|
+
const POLISH_EVIDENCE_FILE_KEYS = Object.freeze(["status", "evidence", "repair_loop_defect", "blockers", "skip_reason"]);
|
|
54
|
+
const POLISH_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
|
|
55
|
+
const POLISH_RECORD_STATUSES = Object.freeze([...POLISH_COMPLETED_STATUSES, "blocked", "skipped"]);
|
|
56
|
+
|
|
57
|
+
const SCHEMA_DIR = fileURLToPath(new URL("../schemas/", import.meta.url));
|
|
58
|
+
const validators = new Map();
|
|
59
|
+
function schemaValidator(file) {
|
|
60
|
+
if (!validators.has(file)) {
|
|
61
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
62
|
+
validators.set(file, ajv.compile(JSON.parse(readFileSync(resolve(SCHEMA_DIR, file), "utf8"))));
|
|
63
|
+
}
|
|
64
|
+
return validators.get(file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Ajv's instancePath (`/theme/repair_loop_defect`) as the dotted field name
|
|
68
|
+
// the rest of the toolkit prints (`theme.repair_loop_defect`).
|
|
69
|
+
function schemaProblems(file, value, label) {
|
|
70
|
+
const validate = schemaValidator(file);
|
|
71
|
+
if (validate(value)) return [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const problems = [];
|
|
74
|
+
for (const error of validate.errors || []) {
|
|
75
|
+
const field = error.instancePath.split("/").filter(Boolean).join(".") || "(root)";
|
|
76
|
+
const line = `${label} ${field} ${error.message}`;
|
|
77
|
+
if (seen.has(line)) continue;
|
|
78
|
+
seen.add(line);
|
|
79
|
+
problems.push(line);
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function typeName(value) {
|
|
85
|
+
if (value === null) return "null";
|
|
86
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function refuseRecord(stage, problems) {
|
|
90
|
+
return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseRecordArgs(args) {
|
|
94
|
+
const stage = args._[1];
|
|
95
|
+
if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
|
|
96
|
+
throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>.`);
|
|
97
|
+
}
|
|
98
|
+
const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : [])]);
|
|
99
|
+
const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
|
|
100
|
+
if (unknown.length) {
|
|
101
|
+
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
|
|
102
|
+
}
|
|
103
|
+
for (const flag of ["context", "report", "run-id", "lifecycle-journal"]) {
|
|
104
|
+
if (Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
105
|
+
}
|
|
106
|
+
if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
|
|
107
|
+
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
108
|
+
}
|
|
109
|
+
if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
110
|
+
return {
|
|
111
|
+
stage,
|
|
112
|
+
packetPath: resolve(requireArg(args, "packet")),
|
|
113
|
+
evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
|
|
114
|
+
dryRun: args["dry-run"] === true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The --evidence file: the polish status, stages.polish.evidence, and the
|
|
119
|
+
// optional theme repair-loop defect. Shape errors name the field and the type
|
|
120
|
+
// that was given; the polish gate names the fields it still finds incomplete.
|
|
121
|
+
export function readPolishEvidenceFile(path) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = readFileSync(path, "utf8");
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(`record polish could not read --evidence ${path}: ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
let input;
|
|
129
|
+
try {
|
|
130
|
+
input = JSON.parse(raw);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new Error(`record polish could not parse --evidence ${path} as JSON: ${error.message}`);
|
|
133
|
+
}
|
|
134
|
+
const problems = [];
|
|
135
|
+
if (!isObject(input)) {
|
|
136
|
+
throw refuseRecord("polish", [`--evidence must hold a JSON object with ${POLISH_EVIDENCE_FILE_KEYS.join(", ")} (got ${typeName(input)}).`]);
|
|
137
|
+
}
|
|
138
|
+
for (const key of Object.keys(input)) {
|
|
139
|
+
if (!POLISH_EVIDENCE_FILE_KEYS.includes(key)) problems.push(`--evidence has unknown key "${key}"; accepted keys: ${POLISH_EVIDENCE_FILE_KEYS.join(", ")}.`);
|
|
140
|
+
}
|
|
141
|
+
const status = input.status === undefined ? "completed" : input.status;
|
|
142
|
+
if (!POLISH_RECORD_STATUSES.includes(status)) {
|
|
143
|
+
problems.push(`status must be one of ${POLISH_RECORD_STATUSES.join(", ")} (got ${JSON.stringify(input.status)}).`);
|
|
144
|
+
}
|
|
145
|
+
// A blocked Polish names what blocks it and a skipped one says why; each
|
|
146
|
+
// key belongs to its own status only.
|
|
147
|
+
const blockers = input.blockers;
|
|
148
|
+
if (status === "blocked") {
|
|
149
|
+
if (!Array.isArray(blockers) || !blockers.length || !blockers.every((blocker) => isObject(blocker) && optionalString(blocker.code) && optionalString(blocker.message))) {
|
|
150
|
+
problems.push(`blockers must be a non-empty array of {"code": "...", "message": "..."} objects when status is blocked (got ${typeName(blockers)}).`);
|
|
151
|
+
}
|
|
152
|
+
} else if (blockers !== undefined) {
|
|
153
|
+
problems.push(`blockers is recorded only with status blocked (status is ${JSON.stringify(status)}).`);
|
|
154
|
+
}
|
|
155
|
+
if (status === "skipped") {
|
|
156
|
+
if (!optionalString(input.skip_reason)) problems.push(`skip_reason must be a non-empty string when status is skipped (got ${typeName(input.skip_reason)}).`);
|
|
157
|
+
} else if (input.skip_reason !== undefined) {
|
|
158
|
+
problems.push(`skip_reason is recorded only with status skipped (status is ${JSON.stringify(status)}).`);
|
|
159
|
+
}
|
|
160
|
+
const evidence = input.evidence;
|
|
161
|
+
const evidenceRequired = POLISH_COMPLETED_STATUSES.includes(status);
|
|
162
|
+
if (!isObject(evidence) && (evidenceRequired || evidence !== undefined)) {
|
|
163
|
+
problems.push(`evidence must be an object carrying ${POLISH_GATE_REQUIRED_EVIDENCE.join(", ")} (got ${typeName(evidence)}).`);
|
|
164
|
+
} else if (isObject(evidence)) {
|
|
165
|
+
if (evidence.issues !== undefined && !Array.isArray(evidence.issues)) {
|
|
166
|
+
problems.push(`evidence.issues must be an array ([] when polish found none) (got ${typeName(evidence.issues)}).`);
|
|
167
|
+
}
|
|
168
|
+
if (evidence.commands !== undefined && !Array.isArray(evidence.commands)) {
|
|
169
|
+
problems.push(`evidence.commands must be an array of the commands polish ran (got ${typeName(evidence.commands)}).`);
|
|
170
|
+
}
|
|
171
|
+
if (evidence.visual_review !== undefined && !isObject(evidence.visual_review)) {
|
|
172
|
+
problems.push(`evidence.visual_review must be an object with a screenshots array (got ${typeName(evidence.visual_review)}).`);
|
|
173
|
+
} else if (isObject(evidence.visual_review) && Object.hasOwn(evidence.visual_review, "page_load")) {
|
|
174
|
+
problems.push(`evidence.visual_review.page_load is written only by ${cmd("polish")} capture; remove it from the file (the captured value on the report is kept).`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (Object.hasOwn(input, "repair_loop_defect") && input.repair_loop_defect !== null && !isObject(input.repair_loop_defect)) {
|
|
178
|
+
problems.push(`repair_loop_defect must be null or an object such as {"code": "...", "message": "..."} (got ${typeName(input.repair_loop_defect)}).`);
|
|
179
|
+
}
|
|
180
|
+
if (problems.length) throw refuseRecord("polish", problems);
|
|
181
|
+
return {
|
|
182
|
+
status,
|
|
183
|
+
evidence: evidence ?? null,
|
|
184
|
+
blockers: status === "blocked" ? blockers : [],
|
|
185
|
+
skipReason: status === "skipped" ? input.skip_reason : null,
|
|
186
|
+
hasRepairLoopDefect: Object.hasOwn(input, "repair_loop_defect"),
|
|
187
|
+
repairLoopDefect: input.repair_loop_defect ?? null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function withoutKeys(object, keys) {
|
|
192
|
+
const copy = { ...object };
|
|
193
|
+
for (const key of keys) delete copy[key];
|
|
194
|
+
return copy;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stageObject(report, key) {
|
|
198
|
+
return isObject(report?.stages?.[key]) ? report.stages[key] : {};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Each composer returns the next report (a copy) and, for setup, the next
|
|
202
|
+
// Build Context, from what doctor computed under the same target lock.
|
|
203
|
+
function composeSetup(report, context, { now, recordedBy }) {
|
|
204
|
+
const setup = {
|
|
205
|
+
...stageObject(report, "setup"),
|
|
206
|
+
stage: "setup",
|
|
207
|
+
status: "completed",
|
|
208
|
+
completed_at: now,
|
|
209
|
+
recorded_by: recordedBy,
|
|
210
|
+
blockers: [],
|
|
211
|
+
};
|
|
212
|
+
const nextReport = { ...report, stages: { ...report.stages, setup } };
|
|
213
|
+
const scaffold = context.scaffold;
|
|
214
|
+
const nextContext = {
|
|
215
|
+
...context,
|
|
216
|
+
scaffold: {
|
|
217
|
+
...scaffold,
|
|
218
|
+
required: false,
|
|
219
|
+
mode: scaffold.mode === "blocked" ? "existing" : scaffold.mode,
|
|
220
|
+
handoff_skill: "next-campaigns-build",
|
|
221
|
+
reason: `Setup recorded by ${recordedBy} at ${now}; the campaign output directory exists.`,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
return { report: nextReport, context: nextContext };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function composeBuild(report, { now, recordedBy, fingerprint }) {
|
|
228
|
+
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
229
|
+
const assembly = {
|
|
230
|
+
...withoutKeys(stageObject(report, "assembly"), ["source_package_material_fingerprint"]),
|
|
231
|
+
stage: "assembly",
|
|
232
|
+
status: "completed",
|
|
233
|
+
build_fingerprint: fingerprint,
|
|
234
|
+
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
235
|
+
completed_at: now,
|
|
236
|
+
recorded_by: recordedBy,
|
|
237
|
+
blockers: [],
|
|
238
|
+
};
|
|
239
|
+
// Polish evidence bound to this exact output stays; anything else is owed
|
|
240
|
+
// again. The evidence object is kept so `polish capture` has somewhere to
|
|
241
|
+
// attach page_load, and its stale identity fields are removed.
|
|
242
|
+
const previousPolish = stageObject(report, "polish");
|
|
243
|
+
const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
|
|
244
|
+
&& optionalString(previousPolish.source_build_fingerprint) === fingerprint;
|
|
245
|
+
const polish = polishStillCurrent
|
|
246
|
+
? previousPolish
|
|
247
|
+
: {
|
|
248
|
+
...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by"]),
|
|
249
|
+
stage: "polish",
|
|
250
|
+
status: "required",
|
|
251
|
+
required_by: "build",
|
|
252
|
+
required_for: ["qa"],
|
|
253
|
+
};
|
|
254
|
+
return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function composePolish(report, { now, recordedBy, fingerprint, input }) {
|
|
258
|
+
const previous = stageObject(report, "polish");
|
|
259
|
+
const previousVisual = isObject(previous.evidence?.visual_review) ? previous.evidence.visual_review : {};
|
|
260
|
+
// A blocked or skipped record given no evidence keeps what is there (the
|
|
261
|
+
// capture's bounded evidence stays for diagnosis).
|
|
262
|
+
const evidence = input.evidence
|
|
263
|
+
? {
|
|
264
|
+
...input.evidence,
|
|
265
|
+
visual_review: {
|
|
266
|
+
...input.evidence.visual_review,
|
|
267
|
+
...(Object.hasOwn(previousVisual, "page_load") ? { page_load: previousVisual.page_load } : {}),
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
: previous.evidence;
|
|
271
|
+
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
272
|
+
const polish = {
|
|
273
|
+
...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence"]),
|
|
274
|
+
stage: "polish",
|
|
275
|
+
status: input.status,
|
|
276
|
+
performed_by: POLISH_PRODUCER,
|
|
277
|
+
source_build_fingerprint: fingerprint,
|
|
278
|
+
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
279
|
+
// A blocked Polish has not completed.
|
|
280
|
+
...(input.status === "blocked" ? {} : { completed_at: now }),
|
|
281
|
+
recorded_by: recordedBy,
|
|
282
|
+
...(evidence === undefined ? {} : { evidence }),
|
|
283
|
+
...(input.skipReason ? { skip_reason: input.skipReason } : {}),
|
|
284
|
+
blockers: input.blockers,
|
|
285
|
+
};
|
|
286
|
+
const nextReport = { ...report, stages: { ...report.stages, polish } };
|
|
287
|
+
// A null defect on a report with no theme block says nothing to record.
|
|
288
|
+
if (input.hasRepairLoopDefect && (isObject(report.theme) || input.repairLoopDefect !== null)) {
|
|
289
|
+
if (!isObject(report.theme)) {
|
|
290
|
+
throw refuseRecord("polish", ["repair_loop_defect was given but the report records no theme; omit it, or record the theme first."]);
|
|
291
|
+
}
|
|
292
|
+
nextReport.theme = { ...report.theme, repair_loop_defect: input.repairLoopDefect };
|
|
293
|
+
}
|
|
294
|
+
return { report: nextReport, context: null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Every check a written record must pass, over exactly what would be written.
|
|
298
|
+
function validateRecord(stage, { report, context, packet, fingerprint }) {
|
|
299
|
+
const problems = [
|
|
300
|
+
...schemaProblems("campaign-runtime-assembly-report.v0.schema.json", report, "Assembly Report"),
|
|
301
|
+
...(context ? schemaProblems("campaign-runtime-build-context.v0.schema.json", context, "Build Context") : []),
|
|
302
|
+
];
|
|
303
|
+
for (const issue of validateAssemblyReport(report).errors) problems.push(`Assembly Report ${issue.code}: ${issue.message}`);
|
|
304
|
+
if (stage === "polish" && POLISH_COMPLETED_STATUSES.includes(report.stages.polish.status) && !problems.length) {
|
|
305
|
+
// The two gates doctor evaluates over the report it reads, evaluated here
|
|
306
|
+
// over the report this record would write. A blocked or skipped Polish is
|
|
307
|
+
// not a pass the gate could grant; doctor keeps QA blocked on it.
|
|
308
|
+
const hiddenEagerMediaGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
|
|
309
|
+
const gate = evaluatePolishGate({ report, hiddenEagerMediaGate, currentOutputFingerprint: fingerprint });
|
|
310
|
+
if (gate.status === "blocked") {
|
|
311
|
+
problems.push(`${gate.code}: ${gate.reason}`);
|
|
312
|
+
for (const problem of gate.problems || []) problems.push(problem);
|
|
313
|
+
for (const action of gate.required_actions || []) {
|
|
314
|
+
if (action?.command) problems.push(`required action: ${action.command}${action.description ? ` (${action.description})` : ""}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (problems.length) throw refuseRecord(stage, problems);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The report must be this packet's before any stage is recorded on it. Two
|
|
322
|
+
// checks, both the ones the toolkit already applies: the prepare-build binding
|
|
323
|
+
// gate doctor evaluates and `next` consumes (derived.prepare_build_gate; its
|
|
324
|
+
// codes are next.prepare_build.context_missing, context_packet_mismatch,
|
|
325
|
+
// context_dsp_mismatch, context_report_missing, report_packet_mismatch,
|
|
326
|
+
// report_context_mismatch, report_campaign_mismatch and report_dsp_mismatch),
|
|
327
|
+
// and the identity match every stage producer requires before it restates an
|
|
328
|
+
// outcome into a report (assemblyReportMatchesPacket), which also covers a
|
|
329
|
+
// packet with no Design Source Package, where the binding gate is not
|
|
330
|
+
// evaluated.
|
|
331
|
+
function bindingProblems(doctor, report, packet) {
|
|
332
|
+
const problems = [];
|
|
333
|
+
const gate = doctor.derived?.prepare_build_gate;
|
|
334
|
+
if (gate?.binding_failure === true) {
|
|
335
|
+
for (const issue of gate.issues || []) problems.push(`${issue.code}: ${issue.message}`);
|
|
336
|
+
if (!problems.length) problems.push(gate.reason);
|
|
337
|
+
}
|
|
338
|
+
if (!assemblyReportMatchesPacket(report, packet) && !problems.some((problem) => problem.startsWith("next.prepare_build.report_campaign_mismatch:"))) {
|
|
339
|
+
problems.push("next.prepare_build.report_campaign_mismatch: Assembly Report campaign identity does not match the current Build Packet.");
|
|
340
|
+
}
|
|
341
|
+
if (problems.length) {
|
|
342
|
+
problems.push("Restore or rebind the Build Context and Assembly Report to this Build Packet; a record never lands on another campaign's report.");
|
|
343
|
+
}
|
|
344
|
+
return problems;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The ladder `next` walks (pickNextStage), up to the stage being recorded:
|
|
348
|
+
// doctor's prepare-build gate, on which `next` answers prepare-build whenever
|
|
349
|
+
// it is set, then every earlier stage terminal by the picker's own predicate.
|
|
350
|
+
function ladderProblems(stage, doctor, report) {
|
|
351
|
+
const gate = doctor.derived?.prepare_build_gate;
|
|
352
|
+
if (gate) return [`next answers prepare-build: ${gate.reason}`];
|
|
353
|
+
const problems = [];
|
|
354
|
+
for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
|
|
355
|
+
const key = reportKeyForCliStage(earlier);
|
|
356
|
+
const status = String(report.stages[key]?.status || "");
|
|
357
|
+
if (!stageIsTerminal(status)) {
|
|
358
|
+
problems.push(`stages.${key}.status is "${status || "(unset)"}", so next answers ${earlier}; run ${cmd("record")} ${earlier} first.`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return problems;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// What doctor computed that the record depends on, checked before anything is
|
|
365
|
+
// composed: the packet/report binding and the ladder (every stage), the output
|
|
366
|
+
// fingerprint (build, polish), the scaffold (setup, build), and the recorded
|
|
367
|
+
// build (polish).
|
|
368
|
+
function doctorFacts(stage, doctor, report, packet) {
|
|
369
|
+
const derived = doctor.derived || {};
|
|
370
|
+
const binding = bindingProblems(doctor, report, packet);
|
|
371
|
+
if (binding.length) throw refuseRecord(stage, binding);
|
|
372
|
+
const ladder = ladderProblems(stage, doctor, report);
|
|
373
|
+
if (ladder.length) throw refuseRecord(stage, ladder);
|
|
374
|
+
if (stage === "setup") {
|
|
375
|
+
const outputDir = optionalString(derived.target_output_dir);
|
|
376
|
+
if (!outputDir || !existsSync(outputDir)) {
|
|
377
|
+
throw refuseRecord(stage, [`The campaign output directory ${outputDir || "(unresolved: packet.assembly.target_repo/output_dir)"} does not exist; scaffold it (next-campaigns-os-setup) before recording setup.`]);
|
|
378
|
+
}
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
const fingerprint = optionalString(derived.build_output_fingerprint?.value);
|
|
382
|
+
if (!fingerprint) {
|
|
383
|
+
const slug = optionalString(derived.public_route_slug) || "<public_route_slug>";
|
|
384
|
+
throw refuseRecord(stage, [`Doctor cannot compute the build output fingerprint: no built output under _site/${slug}/ in the target repo. Run the page-kit build first.`]);
|
|
385
|
+
}
|
|
386
|
+
if (stage === "build" && derived.scaffold_required === true) {
|
|
387
|
+
throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
|
|
388
|
+
}
|
|
389
|
+
if (stage === "polish") {
|
|
390
|
+
const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
|
|
391
|
+
if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
|
|
392
|
+
throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
|
|
393
|
+
}
|
|
394
|
+
if (recorded !== fingerprint) {
|
|
395
|
+
throw refuseRecord(stage, [`The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
fingerprint,
|
|
400
|
+
fingerprintRoot: optionalString(derived.target_repo) && optionalString(derived.build_output_fingerprint?.root)
|
|
401
|
+
? join(derived.target_repo, derived.build_output_fingerprint.root)
|
|
402
|
+
: null,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The last check before the write: the output doctor fingerprinted is still
|
|
407
|
+
// the output on disk. The target lock keeps campaigns-os writers out, but a
|
|
408
|
+
// page-kit build does not take it, so the fingerprint is recomputed here with
|
|
409
|
+
// doctor's own function over doctor's own root, and a change refuses the
|
|
410
|
+
// record instead of stamping a value doctor would then call stale.
|
|
411
|
+
function assertOutputUnchanged(stage, facts) {
|
|
412
|
+
if (!facts.fingerprint) return;
|
|
413
|
+
const current = facts.fingerprintRoot ? computeBuildFingerprint(facts.fingerprintRoot) : { ok: false };
|
|
414
|
+
if (!current.ok || current.fingerprint !== facts.fingerprint) {
|
|
415
|
+
throw refuseRecord(stage, [`The built output changed while recording (doctor read ${facts.fingerprint}, now ${current.ok ? current.fingerprint : "no output"}); let the build finish, then run ${cmd("record")} ${stage} again.`]);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function readPacketFile(stage, packetPath) {
|
|
420
|
+
try {
|
|
421
|
+
return JSON.parse(readFileSync(packetPath, "utf8"));
|
|
422
|
+
} catch (error) {
|
|
423
|
+
throw new Error(`record ${stage}: could not read the Build Packet at ${packetPath}: ${error.message}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Run one `record <stage>` invocation. Returns the result object the CLI
|
|
429
|
+
* prints; throws a refusal for bad argv and an Error, before anything is
|
|
430
|
+
* written, for every other reason a record cannot be made.
|
|
431
|
+
*
|
|
432
|
+
* Test seams: `beforeLock` runs after argv and the --evidence file are read
|
|
433
|
+
* and before the target lock is requested; `afterDoctorRead` runs under the
|
|
434
|
+
* target lock, after doctor has read the target and before anything is
|
|
435
|
+
* composed or written.
|
|
436
|
+
*/
|
|
437
|
+
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null } = {}) {
|
|
438
|
+
const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
|
|
439
|
+
if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
440
|
+
// Operator input, not target state: no campaigns-os writer produces it.
|
|
441
|
+
const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : null;
|
|
442
|
+
const sidecars = {
|
|
443
|
+
contextPath: args.context ? resolve(args.context) : undefined,
|
|
444
|
+
reportPath: args.report ? resolve(args.report) : undefined,
|
|
445
|
+
};
|
|
446
|
+
// The one read before the lock, and only to name the lock: the target repo
|
|
447
|
+
// the packet builds into. Everything the record depends on is read again
|
|
448
|
+
// under it.
|
|
449
|
+
const lockedTarget = targetRepoFor(packetPath, readPacketFile(stage, packetPath));
|
|
450
|
+
if (typeof beforeLock === "function") beforeLock();
|
|
451
|
+
const timestamp = now().toISOString();
|
|
452
|
+
const recordedBy = `campaigns-os record ${stage}`;
|
|
453
|
+
|
|
454
|
+
// A dry run writes nothing, so it takes no lock and creates no lock files
|
|
455
|
+
// (the commitAssemblyReport preview convention); it reads in the same order.
|
|
456
|
+
const run = () => recordUnderLock({
|
|
457
|
+
stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
|
|
458
|
+
});
|
|
459
|
+
const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
|
|
460
|
+
const { composed, facts, reportPath, contextPath, after } = recorded;
|
|
461
|
+
|
|
462
|
+
const stageKey = stage === "build" ? "assembly" : stage;
|
|
463
|
+
const writes = [...(composed.context ? [contextPath] : []), reportPath];
|
|
464
|
+
const ready = [
|
|
465
|
+
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
466
|
+
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
467
|
+
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
468
|
+
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
469
|
+
];
|
|
470
|
+
return {
|
|
471
|
+
ok: true,
|
|
472
|
+
status: dryRun ? "dry_run" : "recorded",
|
|
473
|
+
action: "record",
|
|
474
|
+
stage,
|
|
475
|
+
dry_run: dryRun,
|
|
476
|
+
report_path: reportPath,
|
|
477
|
+
...(composed.context ? { context_path: contextPath } : {}),
|
|
478
|
+
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
479
|
+
build_fingerprint: facts.fingerprint || null,
|
|
480
|
+
record: composed.report.stages[stageKey],
|
|
481
|
+
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
482
|
+
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
483
|
+
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
484
|
+
...(after ? { next_stage: after.next?.stage || null, next_stage_reason: after.next?.reason || null } : {}),
|
|
485
|
+
ready,
|
|
486
|
+
note: dryRun
|
|
487
|
+
? "Dry run: every check passed and nothing was written. Re-run without --dry-run to record."
|
|
488
|
+
: `Recorded. Run ${cmd("next")} --packet <packet> for the next stage.`,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Everything a record reads from the target, in order, all under the target
|
|
493
|
+
// lock (except a dry run, which writes nothing): the packet and the Build
|
|
494
|
+
// Context's report pointer (the workspace), the report, the Build Context
|
|
495
|
+
// (setup), doctor over the same packet and sidecars, the output fingerprint
|
|
496
|
+
// re-check, and the post-write doctor read for `next_stage`. No campaigns-os
|
|
497
|
+
// writer can rebind, rewrite or republish any of them between the read and
|
|
498
|
+
// the write.
|
|
499
|
+
function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead }) {
|
|
500
|
+
// The same workspace `next` resolves, so the record lands in the report
|
|
501
|
+
// `next` reads now, not the one it read before the lock was free.
|
|
502
|
+
const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
|
|
503
|
+
const { packet, contextPath, reportPath } = workspace;
|
|
504
|
+
if (workspace.targetRepo !== lockedTarget) {
|
|
505
|
+
throw refuseRecord(stage, [`The Build Packet was retargeted while this record waited for the target lock (${lockedTarget} is now ${workspace.targetRepo}); run ${cmd("record")} ${stage} again.`]);
|
|
506
|
+
}
|
|
507
|
+
if (!existsSync(reportPath)) throw new Error(`record ${stage}: no Assembly Report at ${reportPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
508
|
+
|
|
509
|
+
let composed = null;
|
|
510
|
+
let facts = null;
|
|
511
|
+
const compose = (report) => {
|
|
512
|
+
if (!isObject(report) || !isObject(report.stages)) throw refuseRecord(stage, [`Assembly Report at ${reportPath} has no stages object.`]);
|
|
513
|
+
const context = stage === "setup" ? readJsonIfExists(contextPath) : null;
|
|
514
|
+
if (stage === "setup" && !isObject(context?.scaffold)) {
|
|
515
|
+
throw new Error(`record setup: no Build Context with a scaffold block at ${contextPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
516
|
+
}
|
|
517
|
+
// The same doctor call `next` makes, so the record stamps the value
|
|
518
|
+
// doctor computes and refuses whatever binding `next` refuses. Doctor
|
|
519
|
+
// resolves the report binding itself; it must be the report this record
|
|
520
|
+
// is about to write.
|
|
521
|
+
const doctor = doctorPacket(packetPath, sidecars);
|
|
522
|
+
const inspected = optionalString(doctor.derived?.assembly_report_path);
|
|
523
|
+
if (!inspected || resolve(inspected) !== resolve(reportPath)) {
|
|
524
|
+
throw refuseRecord(stage, [`The Build Context rebound the Assembly Report while recording (this record read ${reportPath}, doctor now reads ${inspected || "no report"}); run ${cmd("record")} ${stage} again.`]);
|
|
525
|
+
}
|
|
526
|
+
facts = doctorFacts(stage, doctor, report, packet);
|
|
527
|
+
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
528
|
+
const next = stage === "setup"
|
|
529
|
+
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
530
|
+
: stage === "build"
|
|
531
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
|
|
532
|
+
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
|
|
533
|
+
applyDerivedAssemblyReportSummary(next.report);
|
|
534
|
+
validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
|
|
535
|
+
assertOutputUnchanged(stage, facts);
|
|
536
|
+
composed = next;
|
|
537
|
+
if (dryRun) return null;
|
|
538
|
+
// Written inside the report's critical section, after every check and
|
|
539
|
+
// before the report itself, so the two files move together.
|
|
540
|
+
if (next.context) writeJsonAtomic(contextPath, next.context);
|
|
541
|
+
return next.report;
|
|
542
|
+
};
|
|
543
|
+
// Already inside the target lock, which commitAssemblyReport re-enters.
|
|
544
|
+
commitAssemblyReport(workspace, compose, {
|
|
545
|
+
command: `record ${stage}`,
|
|
546
|
+
staleReason: `stages.${stage === "build" ? "assembly" : stage} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
547
|
+
...(dryRun ? { lock: false } : {}),
|
|
548
|
+
});
|
|
549
|
+
const after = dryRun ? null : doctorPacket(packetPath, sidecars);
|
|
550
|
+
return { composed, facts, reportPath, contextPath, after };
|
|
551
|
+
}
|