@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.
Files changed (72) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +648 -5103
  3. package/README.md +32 -11
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  14. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  15. package/contracts/effects.v1.json +1176 -113
  16. package/contracts/orientation-reason-codes.v1.json +7 -0
  17. package/contracts/release-ledger.json +2345 -6087
  18. package/contracts/supported-surface.json +7 -4
  19. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  20. package/docs/brand-theme-bridge.md +81 -0
  21. package/docs/build-packet.md +158 -21
  22. package/docs/campaigns-os-build-flow.md +3 -3
  23. package/docs/design-source-package.md +73 -0
  24. package/docs/effects.md +50 -8
  25. package/docs/gateway-login.md +3 -0
  26. package/docs/local-setup.md +1 -1
  27. package/docs/orientation-contract-reference.md +42 -2
  28. package/docs/polish-evidence.md +74 -0
  29. package/docs/qa-and-test-orders.md +99 -13
  30. package/docs/release-ledger-authoring-guide.md +64 -4
  31. package/docs/runtime-readiness.md +1 -1
  32. package/docs/sdk-storage-compatibility.md +1 -1
  33. package/docs/skills-revision.md +10 -10
  34. package/docs/supported-surface.md +2 -2
  35. package/docs/versioning.md +4 -1
  36. package/package.json +1 -1
  37. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  38. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  39. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  40. package/skills/campaign-readback-classification/SKILL.md +3 -3
  41. package/skills/campaign-run-evidence/SKILL.md +7 -6
  42. package/skills/contribution-intake/SKILL.md +3 -3
  43. package/skills/next-campaigns-build/SKILL.md +7 -6
  44. package/skills/next-campaigns-os/SKILL.md +7 -7
  45. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  46. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  47. package/skills/next-campaigns-polish/SKILL.md +28 -9
  48. package/skills/next-campaigns-qa/SKILL.md +7 -4
  49. package/skills.json +10 -10
  50. package/src/brand-theme.mjs +320 -20
  51. package/src/built-site-scope.mjs +16 -4
  52. package/src/cli.mjs +280 -46
  53. package/src/commercial-parity.mjs +48 -2
  54. package/src/deviation.mjs +13 -1
  55. package/src/diagnostic.mjs +5 -2
  56. package/src/doctor/checks.mjs +320 -81
  57. package/src/doctor/inspect.mjs +55 -13
  58. package/src/doctor/source-provenance.mjs +184 -0
  59. package/src/invocation.mjs +4 -0
  60. package/src/live-campaign-refs.mjs +466 -0
  61. package/src/login.mjs +2 -2
  62. package/src/page-kit-store-profile.mjs +69 -12
  63. package/src/page-kit-sync.mjs +31 -12
  64. package/src/progress-node.mjs +3 -1
  65. package/src/qa-browser.mjs +538 -28
  66. package/src/qa-commercial-parity.mjs +48 -5
  67. package/src/qa-node.mjs +122 -7
  68. package/src/qa-test-order-topology.mjs +148 -0
  69. package/src/sdk-markup.mjs +72 -8
  70. package/src/source-html-intake.mjs +116 -0
  71. package/src/stage-record.mjs +551 -0
  72. 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
+ }