@nextcommerce/campaigns-os 1.41.2 → 1.43.2

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 (83) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +629 -0
  3. package/README.md +8 -6
  4. package/agents/claude/CLAUDE.md +5 -1
  5. package/campaign-spec/dist/types.d.ts +2 -0
  6. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  7. package/contracts/effects.v1.json +118 -25
  8. package/contracts/migration-sidecar-bundle.v0.json +9 -0
  9. package/contracts/release-ledger.json +1424 -0
  10. package/contracts/supported-surface.json +12 -11
  11. package/docs/build-packet.md +120 -8
  12. package/docs/campaigns-os-build-flow.md +3 -2
  13. package/docs/design-source-package.md +89 -15
  14. package/docs/effects.md +83 -2
  15. package/docs/local-setup.md +51 -0
  16. package/docs/migration-sidecar-bundle.md +6 -1
  17. package/docs/orientation-contract-reference.md +1 -1
  18. package/docs/progress-snapshots.md +16 -6
  19. package/docs/qa-and-test-orders.md +157 -17
  20. package/docs/release-ledger-authoring-guide.md +6 -4
  21. package/docs/runtime-readiness.md +1 -1
  22. package/docs/skills-revision.md +10 -10
  23. package/package.json +3 -2
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
  25. package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
  26. package/schemas/campaign-spec.v4.schema.json +4 -0
  27. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
  28. package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
  29. package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
  30. package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +8 -6
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +17 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +4 -4
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +3 -3
  40. package/skills/next-campaigns-qa/SKILL.md +10 -9
  41. package/skills.json +11 -11
  42. package/src/build-brief.mjs +6 -4
  43. package/src/built-script-syntax.mjs +480 -0
  44. package/src/campaigns-api-key.mjs +99 -0
  45. package/src/cli-helpers.mjs +118 -0
  46. package/src/cli.mjs +796 -6963
  47. package/src/design-source-package.mjs +1 -1
  48. package/src/design-source-publication.mjs +898 -0
  49. package/src/diagnostic.mjs +2 -1
  50. package/src/directory-lock.mjs +270 -0
  51. package/src/doctor/checks.mjs +4415 -0
  52. package/src/doctor/inspect.mjs +636 -0
  53. package/src/doctor/next-step.mjs +731 -0
  54. package/src/finding-cause.mjs +14 -10
  55. package/src/install-invocation.mjs +29 -0
  56. package/src/invocation.mjs +179 -0
  57. package/src/lifecycle.mjs +5 -4
  58. package/src/polish-node.mjs +5 -2
  59. package/src/private-template-source.mjs +1 -1
  60. package/src/progress-node.mjs +9 -37
  61. package/src/progress.mjs +5 -3
  62. package/src/proof-policy.mjs +1 -1
  63. package/src/qa-analytics-correctness.mjs +3 -0
  64. package/src/qa-binding-evidence.mjs +76 -11
  65. package/src/qa-browser.mjs +778 -77
  66. package/src/qa-build-scope.mjs +47 -0
  67. package/src/qa-node.mjs +276 -39
  68. package/src/qa-publish.mjs +4 -0
  69. package/src/qa-sidecar.mjs +2 -0
  70. package/src/qa-verdict-discovery.mjs +11 -0
  71. package/src/qa-verdict-publish.mjs +1 -0
  72. package/src/qa-verdict.mjs +8 -1
  73. package/src/readback.mjs +2 -1
  74. package/src/run-record-closeout.mjs +3 -4
  75. package/src/run-record.mjs +4 -0
  76. package/src/sidecar-bundle.mjs +21 -0
  77. package/src/source-html-intake.mjs +1 -1
  78. package/src/source-html-manifest.mjs +9 -2
  79. package/src/spec-source-identity.mjs +44 -0
  80. package/src/stage-ledger.mjs +32 -1
  81. package/src/target-lock.mjs +54 -0
  82. package/src/template-brand-contract.mjs +17 -1
  83. package/src/tooling-setup.mjs +160 -0
@@ -0,0 +1,731 @@
1
+ // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
+ import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
+ import { resolve } from "node:path";
4
+ import { orderPathDepthDriftText } from "../proof-policy.mjs";
5
+ import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
6
+ import {
7
+ SOURCE_PREP_DOCUMENT_WRAPPER,
8
+ SOURCE_PREP_FRONTMATTER_RESIDUE,
9
+ SOURCE_PREP_INTERNAL_LINK_UNROOTED,
10
+ } from "../source-prep.mjs";
11
+ import {
12
+ NEXT_STAGE_ORDER,
13
+ NEXT_STAGE_OWNERS,
14
+ STAGE_TERMINAL_STATUS_PREFIXES,
15
+ reportKeyForCliStage,
16
+ stageIsBlocked,
17
+ stageIsTerminal,
18
+ } from "../orchestration-stage-contract.mjs";
19
+ import { evaluatePolishGate } from "../polish-gate.mjs";
20
+ import { cmd } from "../install-invocation.mjs";
21
+ import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
22
+ import { orderPathDepthDrift } from "./checks.mjs";
23
+
24
+ // The orchestration stage contract lives in orchestration-stage-contract.mjs so
25
+ // report producers, validators, the `next` picker and the Assembly Report's
26
+ // derived summary share one deterministic source for stage order, terminal
27
+ // status prefixes, stage owners and CLI-stage/report-key translation.
28
+ const POLISH_GATE_BUILD_RERUN_CODES = Object.freeze(new Set([
29
+ "polish.assembly_source_package_fingerprint_missing",
30
+ "polish.assembly_source_package_stale",
31
+ ]));
32
+
33
+
34
+ function polishGateRequiresBuild(polishGate) {
35
+ return polishGate?.status === "blocked" && POLISH_GATE_BUILD_RERUN_CODES.has(polishGate.code);
36
+ }
37
+
38
+ // Gate → issue, the one way. Doctor reports a blocked gate under the gate's
39
+ // own code — the theme gate as a warning, because its fix happens in build —
40
+ // and `next` reports the same gate under `next.<stage>.<code>` as an error
41
+ // for the stage it blocks. The polish gates ride on the issue's `detail`, so
42
+ // "doctor's only errors are the polish gates" is read back from the issues
43
+ // themselves rather than decoded from a code prefix.
44
+ export function gateIssue(kind, gate, { stage = null } = {}) {
45
+ const commands = (gate?.required_actions || []).map((action) => action?.command).filter(Boolean);
46
+ const run = commands.length ? ` Run: ${commands.join(" | ")}` : "";
47
+ const requiredAction = commands.length ? ` Required action: ${commands.join(" | ")}.` : "";
48
+ switch (kind) {
49
+ case "theme_gate":
50
+ return stage
51
+ ? { severity: "error", code: `next.${stage}.theme_gate`, message: `${gate.reason}${run}`, detail: { theme_gate: gate } }
52
+ : { severity: "warning", code: gate.code, message: `${gate.reason} Polish/deploy/QA are gated until resolved.${run}`, detail: null };
53
+ case "polish_gate":
54
+ return {
55
+ severity: "error",
56
+ code: stage ? `next.${stage}.${gate.code}` : gate.code,
57
+ message: `${gate.reason}${requiredAction || " Run next-campaigns-polish before QA."}`,
58
+ detail: { polish_gate: gate },
59
+ };
60
+ case "polish_checkpoint_gate":
61
+ return {
62
+ severity: "error",
63
+ code: stage ? `next.${stage}.${gate.code}` : gate.code,
64
+ message: stage ? gate.reason : `${gate.reason}${requiredAction}`,
65
+ detail: { polish_checkpoint_gate: gate },
66
+ };
67
+ default:
68
+ throw new Error(`Unknown gate kind: ${kind}`);
69
+ }
70
+ }
71
+
72
+ function pushGateIssue({ errors, warnings }, issue) {
73
+ addIssue(issue.severity === "error" ? errors : warnings, issue.code, issue.message, issue.detail);
74
+ }
75
+
76
+ // Doctor's errors are only the polish gates' projections: the issues carry
77
+ // the gate they came from, so this reads data, not a code prefix.
78
+ export function doctorErrorsAreOnlyPolishGate(errors = []) {
79
+ return errors.length > 0 && errors.every((issue) => Boolean(issue?.detail?.polish_gate || issue?.detail?.polish_checkpoint_gate));
80
+ }
81
+
82
+ function reportStageBlockerIssues(reportStage, fallbackCode, fallbackMessage) {
83
+ const blockers = Array.isArray(reportStage?.blockers) ? reportStage.blockers : [];
84
+ if (!blockers.length) return [{ code: fallbackCode, message: fallbackMessage }];
85
+ return blockers.map((blocker) => ({
86
+ code: blocker.code || fallbackCode,
87
+ message: blocker.message || fallbackMessage,
88
+ detail: blocker,
89
+ }));
90
+ }
91
+
92
+ function designSourceReferenceMismatches(expected, expectedArtifactPath, actual, actualArtifactPath) {
93
+ if (!isObject(expected) || !isObject(actual)) return ["reference"];
94
+ const mismatches = [];
95
+ for (const field of ["schema_version", "sha256", "material_fingerprint"]) {
96
+ if (expected[field] !== actual[field]) mismatches.push(field);
97
+ }
98
+ const expectedPath = resolveFromFile(expectedArtifactPath, expected.path);
99
+ const actualPath = resolveFromFile(actualArtifactPath, actual.path);
100
+ if (!filesystemPathsMatch(expectedPath, actualPath)) mismatches.push("path");
101
+ return mismatches;
102
+ }
103
+
104
+ function nextPrepareBuildBindingIssues({
105
+ packet,
106
+ packetPath,
107
+ context,
108
+ contextPath,
109
+ report,
110
+ reportPath,
111
+ targetRepo,
112
+ explicitReport,
113
+ }) {
114
+ if (!isObject(packet?.design_source_package)) return [];
115
+ const issues = [];
116
+ const push = (code, message, detail = null) => issues.push({ code, message, detail });
117
+
118
+ if (!isObject(context)) {
119
+ push(
120
+ "next.prepare_build.context_missing",
121
+ `Build Context is unavailable at ${contextPath}; the Design Source Package lifecycle report cannot be bound to the current packet.`,
122
+ );
123
+ } else {
124
+ const contextPacketPointer = optionalString(context.packet_path);
125
+ const contextPacketPath = contextPacketPointer ? resolve(targetRepo, contextPacketPointer) : null;
126
+ if (!contextPacketPath || !filesystemPathsMatch(contextPacketPath, packetPath)) {
127
+ push(
128
+ "next.prepare_build.context_packet_mismatch",
129
+ "Build Context packet_path does not identify the current Build Packet; refusing to select a lifecycle report from that context.",
130
+ { expected_packet_path: packetPath, recorded_packet_path: contextPacketPath },
131
+ );
132
+ }
133
+
134
+ const contextDspMismatches = designSourceReferenceMismatches(
135
+ packet.design_source_package,
136
+ packetPath,
137
+ context.design_source_package,
138
+ contextPath,
139
+ );
140
+ if (contextDspMismatches.length) {
141
+ push(
142
+ "next.prepare_build.context_dsp_mismatch",
143
+ `Build Context Design Source Package reference does not match the current packet (${contextDspMismatches.join(", ")}).`,
144
+ { mismatched_fields: contextDspMismatches },
145
+ );
146
+ }
147
+
148
+ if (!explicitReport && !optionalString(context.report_path)) {
149
+ push(
150
+ "next.prepare_build.context_report_missing",
151
+ "Build Context does not record report_path; packet-only next cannot prove which lifecycle report belongs to this packet.",
152
+ );
153
+ }
154
+ }
155
+
156
+ if (!isObject(report)) return issues;
157
+
158
+ const reportPacketPointer = optionalString(report.inputs?.packet_path);
159
+ const reportPacketPath = reportPacketPointer ? resolve(targetRepo, reportPacketPointer) : null;
160
+ if (!reportPacketPath || !filesystemPathsMatch(reportPacketPath, packetPath)) {
161
+ push(
162
+ "next.prepare_build.report_packet_mismatch",
163
+ "Assembly Report inputs.packet_path does not identify the current Build Packet.",
164
+ { expected_packet_path: packetPath, recorded_packet_path: reportPacketPath },
165
+ );
166
+ }
167
+
168
+ const reportContextPointer = optionalString(report.inputs?.context_path);
169
+ const reportContextPath = reportContextPointer ? resolve(targetRepo, reportContextPointer) : null;
170
+ if (!reportContextPath || !filesystemPathsMatch(reportContextPath, contextPath)) {
171
+ push(
172
+ "next.prepare_build.report_context_mismatch",
173
+ "Assembly Report inputs.context_path does not identify the selected Build Context.",
174
+ { expected_context_path: contextPath, recorded_context_path: reportContextPath },
175
+ );
176
+ }
177
+
178
+ const expectedMapId = optionalString(packet.spec?.map_id);
179
+ const expectedSlug = optionalString(packet.campaign?.public_route_slug);
180
+ const recordedMapId = optionalString(report.identity?.map_id);
181
+ const recordedSlug = optionalString(report.identity?.public_route_slug);
182
+ if (!campaignIdentitiesMatch(packet.spec, report.identity) || recordedSlug !== expectedSlug) {
183
+ push(
184
+ "next.prepare_build.report_campaign_mismatch",
185
+ "Assembly Report campaign identity does not match the current Build Packet.",
186
+ {
187
+ // Failed identity fields are diagnostic data, never adopted evidence.
188
+ expected: { map_id: expectedMapId, local_spec_id: packet.spec?.local_spec_id ?? null, public_route_slug: expectedSlug },
189
+ recorded: { map_id: recordedMapId, local_spec_id: report.identity?.local_spec_id ?? null, public_route_slug: recordedSlug },
190
+ },
191
+ );
192
+ }
193
+
194
+ const reportDspMismatches = designSourceReferenceMismatches(
195
+ packet.design_source_package,
196
+ packetPath,
197
+ report.design_source_package,
198
+ reportPath,
199
+ );
200
+ const contextDspMismatches = isObject(context)
201
+ ? designSourceReferenceMismatches(
202
+ context.design_source_package,
203
+ contextPath,
204
+ report.design_source_package,
205
+ reportPath,
206
+ )
207
+ : [];
208
+ const dspMismatches = [...new Set([...reportDspMismatches, ...contextDspMismatches])];
209
+ if (dspMismatches.length) {
210
+ push(
211
+ "next.prepare_build.report_dsp_mismatch",
212
+ `Assembly Report Design Source Package reference does not match the current packet/context (${dspMismatches.join(", ")}).`,
213
+ { mismatched_fields: dspMismatches },
214
+ );
215
+ }
216
+
217
+ return issues;
218
+ }
219
+
220
+ function uniquePrepareBuildBlockers(blockers) {
221
+ const unique = new Map();
222
+ for (const blocker of blockers) {
223
+ if (!isObject(blocker)) continue;
224
+ // Stage and top-level report lists intentionally mirror blockers. Collapse
225
+ // only complete semantic duplicates: field/detail/index evidence must not
226
+ // disappear merely because the user-facing header is the same.
227
+ const canonicalizeBlocker = (value) => {
228
+ if (Array.isArray(value)) return value.map(canonicalizeBlocker);
229
+ if (!isObject(value)) return value;
230
+ return Object.fromEntries(
231
+ Object.keys(value)
232
+ .filter((key) => value[key] !== undefined)
233
+ .sort()
234
+ .map((key) => [key, canonicalizeBlocker(value[key])]),
235
+ );
236
+ };
237
+ const key = JSON.stringify(canonicalizeBlocker(blocker));
238
+ if (!unique.has(key)) unique.set(key, blocker);
239
+ }
240
+ return [...unique.values()];
241
+ }
242
+
243
+ function prepareBuildGateIssue(report, { required = false, reportPath = null, bindingIssues = [] } = {}) {
244
+ const stage = report?.stages?.prepare_build;
245
+ if (bindingIssues.length) {
246
+ return {
247
+ stage,
248
+ status: "mismatched",
249
+ blocked: true,
250
+ binding_failure: true,
251
+ issues: bindingIssues,
252
+ reason: `The selected Build Context or Assembly Report is not bound to the current Build Packet (${bindingIssues.map((issue) => issue.code).join(", ")}); refusing to bypass prepare-build.`,
253
+ };
254
+ }
255
+ if (!stage) {
256
+ if (!required) return null;
257
+ const location = reportPath ? ` at ${reportPath}` : "";
258
+ return {
259
+ stage: null,
260
+ status: "missing",
261
+ blocked: true,
262
+ reason: report
263
+ ? `The lifecycle assembly report${location} does not record stages.prepare_build; continuing would bypass the prepare-build gate.`
264
+ : `The lifecycle assembly report${location} is unavailable; continuing would bypass the prepare-build gate. Restore the recorded report or rerun prepare-build/start before continuing.`,
265
+ };
266
+ }
267
+ const status = String(stage.status || "");
268
+ const stageBlockers = Array.isArray(stage.blockers) ? stage.blockers : [];
269
+ const topLevelDspBlockers = (Array.isArray(report?.blockers) ? report.blockers : [])
270
+ .filter((blocker) => blocker?.code === "DESIGN_SOURCE_PACKAGE_NOT_READY");
271
+ const contradictoryBlockers = uniquePrepareBuildBlockers([...stageBlockers, ...topLevelDspBlockers]);
272
+ // report.status is derived from the stages on every write, so a report that
273
+ // has been through commitAssemblyReport reads "blocked" if and only if some
274
+ // stage is blocked, and a blocked QA or doctor stage beside a terminal
275
+ // prepare_build is not a contradiction. The case below can only be a report
276
+ // written before the summary was derived (or hand-edited since): a
277
+ // top-level "blocked" that no recorded stage explains. It is still a
278
+ // contradiction to refuse on, and the next commit of the report heals it.
279
+ const blockedStatusFromPreDerivationReport = report?.status === "blocked" && !anyAssemblyReportStageBlocked(report);
280
+ if (stageIsTerminal(status) && (
281
+ blockedStatusFromPreDerivationReport
282
+ || stageBlockers.length > 0
283
+ || topLevelDspBlockers.length > 0
284
+ )) {
285
+ const contradictions = [
286
+ ...(blockedStatusFromPreDerivationReport ? ["report.status=blocked"] : []),
287
+ ...(stageBlockers.length ? [`stages.prepare_build.blockers=${stageBlockers.length}`] : []),
288
+ ...(topLevelDspBlockers.length ? [`top-level DSP blockers=${topLevelDspBlockers.length}`] : []),
289
+ ];
290
+ return {
291
+ stage,
292
+ status,
293
+ blocked: true,
294
+ blockers: contradictoryBlockers,
295
+ reason: `Stage "prepare_build" claims terminal status "${status}" but retained blocking evidence contradicts it (${contradictions.join(", ")}); resolve the report before continuing.`,
296
+ };
297
+ }
298
+ if (stageIsTerminal(status)) return null;
299
+ return {
300
+ stage,
301
+ status,
302
+ blocked: stageIsBlocked(status),
303
+ reason: stageIsBlocked(status)
304
+ ? `Stage "prepare_build" is blocked (status="${status}"); resolve prepare-build blockers before continuing.`
305
+ : `Stage "prepare_build" has status "${status || "(unset)"}"; rerun prepare-build before continuing.`,
306
+ };
307
+ }
308
+
309
+ function addPrepareBuildGateErrors(errors, report, gate = prepareBuildGateIssue(report)) {
310
+ if (!gate) return false;
311
+ // Doctor's own checks run BEFORE this gate merges in the recorded
312
+ // prepare-build blockers: doctorPacket calls validatePacket/runDoctorChecks
313
+ // first and then surfaces the gate, and nextStage seeds its error list from
314
+ // doctor.errors before calling this (doctor and the ladder agree, #238).
315
+ // Two dedup keys keep the merged list from reporting one problem twice:
316
+ // an exact [code, message, detail] match drops a blocker doctor already
317
+ // surfaced verbatim, and a page-level match drops a MISSING_SOURCE_PAGE
318
+ // blocker when the source-coverage check already named the same missing
319
+ // page under its own code (source_html.pages.coverage).
320
+ const seen = new Set(errors.map((issue) => JSON.stringify([issue.code, issue.message, issue.detail ?? null])));
321
+ const coveredPageIds = new Set(
322
+ errors
323
+ .filter((issue) => issue.code === "source_html.pages.coverage")
324
+ .map((issue) => issue.detail?.page_id)
325
+ .filter(isNonEmptyString),
326
+ );
327
+ const addUnique = (code, message, detail) => {
328
+ const key = JSON.stringify([code, message, detail ?? null]);
329
+ if (seen.has(key)) return;
330
+ if (code === "MISSING_SOURCE_PAGE" && isNonEmptyString(detail?.page_id) && coveredPageIds.has(detail.page_id)) return;
331
+ seen.add(key);
332
+ addIssue(errors, code, message, detail);
333
+ };
334
+ if (Array.isArray(gate.issues) && gate.issues.length) {
335
+ for (const issue of gate.issues) addUnique(issue.code, issue.message, issue.detail || null);
336
+ return true;
337
+ }
338
+ const blockerSource = Array.isArray(gate.blockers) && gate.blockers.length
339
+ ? { blockers: gate.blockers }
340
+ : gate.stage;
341
+ for (const issue of reportStageBlockerIssues(
342
+ blockerSource,
343
+ gate.stage ? "next.prepare_build" : "next.prepare_build.report_unavailable",
344
+ gate.reason,
345
+ )) {
346
+ addUnique(issue.code, issue.message, issue.detail || null);
347
+ }
348
+ return true;
349
+ }
350
+
351
+ // Declared order-path depths that ask for no purchase at all. A packet may
352
+ // legitimately declare one: `--test-order off` diagnostics stay intentional.
353
+ const ORDER_PATH_DEPTHS_WITHOUT_PURCHASE = new Set(["off", "none", "skip", "not_required", "unspecified"]);
354
+
355
+ export function assessPurchaseProofCoverage({ packet = null, report = null } = {}) {
356
+ const packetDepth = optionalString(packet?.qa?.proof_policy?.order_path_depth);
357
+ const reportDepth = optionalString(report?.proof_policy?.order_path_depth);
358
+ // The packet is author intent and the report is the assembly-time echo of it,
359
+ // so the packet wins — but only when the two actually agree. A hand-edit or a
360
+ // stale report mirror can leave them disagreeing, and silently preferring the
361
+ // packet then lets a corrupted pair decide the gate. Neither value is
362
+ // trustworthy in that state, so the coverage is genuinely unknown: advisory,
363
+ // never a silent unblock, and named loudly enough that an operator can see
364
+ // which two artifacts to reconcile.
365
+ const drift = orderPathDepthDrift(packet, report);
366
+ if (drift) {
367
+ return {
368
+ state: "unknown",
369
+ // Neither side is trustworthy, so there is no single declared depth to
370
+ // report; both values are exposed structurally so a consumer never has
371
+ // to parse the reason to learn that the two artifacts disagree.
372
+ declared_depth: null,
373
+ declared_depths: { packet: drift.packet, report: drift.report },
374
+ // The reason names the one command that reconciles them (the same text
375
+ // doctor's warning carries); the packet path is substituted by `next`.
376
+ reason: orderPathDepthDriftText({ packetDepth: drift.packet, reportDepth: drift.report }),
377
+ };
378
+ }
379
+ const declared = packetDepth || reportDepth;
380
+ const declaredDepths = { packet: packetDepth || null, report: reportDepth || null };
381
+ if (!declared || ORDER_PATH_DEPTHS_WITHOUT_PURCHASE.has(declared.toLowerCase())) {
382
+ return {
383
+ state: "not_required",
384
+ declared_depth: declared || null,
385
+ declared_depths: declaredDepths,
386
+ reason: "No order-path depth is declared, so no purchase proof is owed.",
387
+ };
388
+ }
389
+ const summary = report?.stages?.qa?.purchase_proof;
390
+ if (!isObject(summary) || !Number.isInteger(summary.order_paths_executed)) {
391
+ return {
392
+ state: "unknown",
393
+ declared_depth: declared,
394
+ declared_depths: declaredDepths,
395
+ reason: "The assembly report's qa stage records no purchase-proof summary, so the depth QA exercised cannot be read from it.",
396
+ };
397
+ }
398
+ if (summary.order_paths_executed > 0) {
399
+ return {
400
+ state: "satisfied",
401
+ declared_depth: declared,
402
+ declared_depths: declaredDepths,
403
+ reason: `QA executed ${summary.order_paths_executed} order path(s) against a declared "${declared}" depth.`,
404
+ };
405
+ }
406
+ return {
407
+ state: "unmet",
408
+ declared_depth: declared,
409
+ declared_depths: declaredDepths,
410
+ reason: `QA recorded zero executed order paths, so a declared "${declared}" order-path depth is not proved. A \`--test-order off\` run is a diagnostic, not purchase proof; re-run QA at the declared depth or change the declared depth deliberately.`,
411
+ };
412
+ }
413
+
414
+ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
415
+ const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
416
+ const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
417
+ // prepare-build is the earliest lifecycle prerequisite. Surface its
418
+ // authoritative blockers before later doctor findings so a blocked Design
419
+ // Source Package can never be mistaken for permission to enter setup/build.
420
+ if (prepareBuildGate) {
421
+ return {
422
+ stage: "prepare-build",
423
+ reason: prepareBuildGate.reason,
424
+ blocked: true,
425
+ };
426
+ }
427
+
428
+ if (errors.length && !doctorErrorsAreOnlyPolishGate(errors)) {
429
+ return {
430
+ stage: "doctor-blocked",
431
+ reason: `Doctor reported ${errors.length} blocker(s); resolve them before any stage runs.`,
432
+ };
433
+ }
434
+
435
+ if (!report || !report.stages) {
436
+ return {
437
+ stage: "setup",
438
+ reason: "No assembly report on disk yet. Start with setup (assembly report should appear after prepare-build).",
439
+ };
440
+ }
441
+
442
+ if (polishGate.status === "blocked") {
443
+ if (polishGateRequiresBuild(polishGate)) {
444
+ return {
445
+ stage: "build",
446
+ reason: polishGate.reason,
447
+ };
448
+ }
449
+ return {
450
+ stage: "polish",
451
+ reason: polishGate.reason,
452
+ blocked: true,
453
+ };
454
+ }
455
+
456
+ if (polishCheckpointGate?.status === "blocked") {
457
+ return {
458
+ stage: "polish",
459
+ reason: polishCheckpointGate.reason,
460
+ blocked: true,
461
+ };
462
+ }
463
+
464
+ for (const cliStage of NEXT_STAGE_ORDER) {
465
+ const reportKey = reportKeyForCliStage(cliStage);
466
+ const stage = report.stages[reportKey];
467
+ if (!stage) {
468
+ return {
469
+ stage: cliStage,
470
+ reason: `Stage "${reportKey}" is not recorded in the assembly report; run "${cliStage}" next.`,
471
+ };
472
+ }
473
+ const status = String(stage.status || "");
474
+ if (stageIsBlocked(status)) {
475
+ return {
476
+ stage: cliStage,
477
+ reason: `Stage "${reportKey}" is blocked (status="${status}"); unblock before continuing.`,
478
+ blocked: true,
479
+ };
480
+ }
481
+ // Match by prefix so "completed_with_warnings" and future suffixes
482
+ // (e.g. "completed_partial") count as terminal.
483
+ const isTerminal = STAGE_TERMINAL_STATUS_PREFIXES.some((t) => status.startsWith(t));
484
+ if (!isTerminal) {
485
+ return {
486
+ stage: cliStage,
487
+ reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
488
+ };
489
+ }
490
+ // A terminal QA status is not the same claim as purchase proof. QA finalizes
491
+ // a verdict and records a terminal status even when no order path ran, so a
492
+ // `--test-order off` diagnostic used to carry the pipeline to "done" against
493
+ // a packet declaring common depth. Only an EXPLICIT zero blocks: an absent
494
+ // summary is unknown and stays advisory.
495
+ if (cliStage === "qa" && purchaseProof?.state === "unmet") {
496
+ return {
497
+ stage: "qa",
498
+ reason: purchaseProof.reason,
499
+ };
500
+ }
501
+ }
502
+
503
+ return {
504
+ stage: "done",
505
+ reason: "All stages are in a terminal status (completed / completed_with_warnings / skipped). Pipeline is complete.",
506
+ };
507
+ }
508
+
509
+ // Packet 03 (INV-5 first slice): the deploy-output URL scan that
510
+ // buildNextStep's deploySatisfied has always used, extracted so
511
+ // detectLedgerDivergence reads the exact same artifact signal instead of
512
+ // growing a second, slightly different scan.
513
+ function deployUrlFromReportOutputs(report) {
514
+ for (const output of report?.stages?.deploy?.outputs || []) {
515
+ if (/^https?:\/\//.test(String(output))) return String(output);
516
+ }
517
+ return null;
518
+ }
519
+
520
+ function checkpointExceptionPresent(derived) {
521
+ return (Array.isArray(derived?.checkpoint_gates)
522
+ && derived.checkpoint_gates.some((gate) => gate?.status === "waived"))
523
+ || derived?.polish_checkpoint_gate?.status === "waived";
524
+ }
525
+
526
+ function readinessStatus(warnings, derived) {
527
+ if (checkpointExceptionPresent(derived)) return "ready_with_waivers";
528
+ return warnings.length ? "ready_with_warnings" : "ready";
529
+ }
530
+
531
+ // The source-preparation action names only the repairs the findings ask for.
532
+ // A document-wrapper finding reported as a warning is the accepted
533
+ // preserve_document_wrappers adapter decision (src/source-prep.mjs decides
534
+ // the severity from the packet's wrapper_policy); ordering a wrapper strip
535
+ // there would undo the decision that cleared the gate, so the strip step is
536
+ // offered only when the finding is an error.
537
+ function sourcePreparationAction(errors, warnings) {
538
+ const errorCodes = new Set(errors.map((issue) => issue.code));
539
+ const warningCodes = new Set(warnings.map((issue) => issue.code));
540
+ const present = (code) => errorCodes.has(code) || warningCodes.has(code);
541
+ const steps = [];
542
+ if (errorCodes.has(SOURCE_PREP_DOCUMENT_WRAPPER)) steps.push("strip document wrappers");
543
+ if (present(SOURCE_PREP_FRONTMATTER_RESIDUE)) steps.push("repair leftover frontmatter");
544
+ if (present(SOURCE_PREP_INTERNAL_LINK_UNROOTED)) steps.push("route internal links through campaign_link/CampaignSpec routes");
545
+ if (!steps.length) return null;
546
+ const listed = steps.length === 1 ? steps[0] : `${steps.slice(0, -1).join(", ")}, and ${steps[steps.length - 1]}`;
547
+ return `Prepare the mapped source HTML for page-kit ingestion — ${listed} (docs/quickstart.md "Prepare Raw HTML Source") — then rerun ${cmd("doctor")}.`;
548
+ }
549
+
550
+ // Owner and skill for each stage the picker can name. The doctor's `next`
551
+ // block is a projection of the same picker the `next` command runs
552
+ // (pickNextStage), so the two can no longer disagree about which stage comes
553
+ // next: doctor used to carry its own decider with its own vocabulary
554
+ // (collect-inputs / assembly / complete) and its own gating, which knew
555
+ // neither purchase proof nor the prepare-build gate, and listed the stage it
556
+ // recommended inside blocked_stages. The table itself lives on the stage
557
+ // contract so the Assembly Report's derived `next` spells owners the same way.
558
+ export const DOCTOR_NEXT_STAGE_OWNERS = NEXT_STAGE_OWNERS;
559
+
560
+ // The code -> action strings doctor prints under `Next:`. They describe the
561
+ // repairs the findings ask for and are independent of which stage the picker
562
+ // names, so they survive the picker consolidation unchanged.
563
+ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef = derived.packet_path || "<packet>" }) {
564
+ const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
565
+ const onlyPolishErrors = doctorErrorsAreOnlyPolishGate(errors);
566
+ const actions = [];
567
+ if (errors.length && !onlyPolishErrors) {
568
+ actions.push("Resolve packet blockers before assembly.");
569
+ }
570
+ if (codes.has("assembly.template_lock")) actions.push("Lock a template family before commerce wiring.");
571
+ if (codes.has("spec.page_url_html_extension")) {
572
+ actions.push("Update CampaignSpec page_url values to Page Kit public routes such as landing/ or checkout/, not source filenames like landing.html.");
573
+ }
574
+ if (codes.has("spec.route_collision")) {
575
+ actions.push("Fix Campaign Map page_url values so every active page resolves to a unique Page Kit route.");
576
+ }
577
+ if (codes.has("frontmatter.demoOnlyValues") || codes.has("frontmatter.replaceFromSpecOrApi")) {
578
+ actions.push("Use the template agentContract to replace demo values from CampaignSpec/API.");
579
+ }
580
+ if (codes.has("template_contract.brand_contract") || codes.has("template_contract.family_inventory")) {
581
+ actions.push(`Add or repair the selected family's contracts/template-brand-contract.<family>.v0.json, then rerun ${cmd("doctor")} --packet <packet>.`);
582
+ }
583
+ if (codes.has("template_contract.exit_pop") || codes.has("template_contract.exit_pop_residue") || codes.has("template_contract.exit_pop_blank_widget")) {
584
+ actions.push("Strip the default exit-pop widget or wire CampaignSpec checkout exit_intent/promo_code_input to the SDK coupon path before QA.");
585
+ }
586
+ if (codes.has("template_contract.discount_claim_unverified")) {
587
+ actions.push("Confirm any rendered promo discount percentage claims against the build request, merchant notes, or CampaignSpec before launch.");
588
+ }
589
+ if (codes.has("template_contract.placeholder_text_residue")) {
590
+ actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
591
+ }
592
+ if (codes.has("template_contract.demo_asset_residue")) {
593
+ actions.push("Re-skin template demo placeholder assets (spacer SVGs, repeated benefit icons, starter imagery) to the campaign's real assets before launch.");
594
+ }
595
+ if (codes.has("content_residue.needs_merchant_input")) {
596
+ actions.push("Collect the flagged merchant inputs (byline identity, proof data) via the attestation lane, re-inject the slots, and rebuild — the needs-input marker must never ship.");
597
+ }
598
+ if (codes.has("content_residue.unverified_urgency")) {
599
+ actions.push("Blank the urgency slots (countdown/sell-out) or record verified offer urgency in the brief payload's offer.urgency, then rebuild.");
600
+ }
601
+ if (codes.has("proof_attestation.pending_shipped") || codes.has("proof_attestation.non_attestable_shipped")) {
602
+ actions.push("Resolve shipped proof against the brief payload's proof_assets: collect the merchant's click-wrap attestation for attestable items; remove non-attestable proof outright.");
603
+ }
604
+ if (codes.has("scope.partial_build")) {
605
+ actions.push("Build and deploy only the mapped partial-scope pages; label the preview as route/visual-testable, not full-funnel launch-ready.");
606
+ }
607
+ const sourcePrepAction = sourcePreparationAction(errors, warnings);
608
+ if (sourcePrepAction) actions.push(sourcePrepAction);
609
+ if (codes.has("scope.runtime_qa_blocked")) {
610
+ actions.push("Keep checkout/order-proof QA blocked until the out-of-scope runtime pages are built or explicitly delegated to an existing downstream URL.");
611
+ }
612
+ // A recorded setup is not a live scaffold. The picker still names build
613
+ // (and `next build` refuses with next.build.setup), so the recovery is
614
+ // spelled out here rather than by disagreeing with the picker.
615
+ if (codes.has("page_kit.scaffold_required")) {
616
+ actions.push(`Target campaign output directory is missing; run ${cmd("next")} setup --packet ${packetRef} before build.`);
617
+ }
618
+ if (polishBlocked) {
619
+ if (polishGate.status === "blocked") {
620
+ actions.push(`${polishGate.reason} Run next-campaigns-polish and record structured evidence before deploy/QA handoff.`);
621
+ }
622
+ if (polishCheckpointGate?.status === "blocked") {
623
+ actions.push(`${polishCheckpointGate.reason} Run ${cmd("polish")} capture before marking Polish complete.`);
624
+ }
625
+ }
626
+ return actions;
627
+ }
628
+
629
+ // Doctor's `next` block: the `next` command's picker, projected. `stage` and
630
+ // `reason` come from pickNextStage over the same report, doctor result and
631
+ // purchase-proof summary the `next` command reads; `blocked_stages` lists the
632
+ // stages AFTER the picked one that cannot run until it clears, never the
633
+ // picked stage itself; `command` is always present.
634
+ function buildNextStep(errors, warnings, derived, report = null, packet = null, prepareBuildGate = prepareBuildGateIssue(report), { sidecarArgs = "" } = {}) {
635
+ const polishGate = derived.polish_gate || evaluatePolishGate({ report });
636
+ const polishCheckpointGate = derived.polish_checkpoint_gate || null;
637
+ const assemblyComplete = String(report?.stages?.assembly?.status || "").startsWith("completed");
638
+ const polishBlocked = assemblyComplete
639
+ && (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
640
+ const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
641
+ const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
642
+ const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
643
+ // The picker's vocabulary and this table must not drift apart: a stage the
644
+ // table does not know would otherwise be relabelled as an operator step and
645
+ // sliced into the whole ladder. Fail loudly instead.
646
+ if (!Object.hasOwn(DOCTOR_NEXT_STAGE_OWNERS, picked.stage)) {
647
+ throw new Error(`Doctor has no owner for next stage "${picked.stage}"; add it to DOCTOR_NEXT_STAGE_OWNERS.`);
648
+ }
649
+ // An explicit --context / --report is carried into every recommended
650
+ // command, so a recovery reads the same artifacts the recommendation did.
651
+ const packetRef = `${derived.packet_path || "<packet>"}${sidecarArgs}`;
652
+ const actions = doctorNextActions(errors, warnings, derived, { polishBlocked, polishGate, polishCheckpointGate, packetRef });
653
+ const deployStatus = String(report?.stages?.deploy?.status || "");
654
+ const deploySatisfied = ["completed", "completed_with_warnings", "ready_with_exceptions"].some((prefix) => deployStatus.startsWith(prefix))
655
+ || Boolean(deployUrlFromReportOutputs(report));
656
+ // qa is not runnable without a URL to test (`next qa` refuses with
657
+ // next.qa.deploy_url), so a picked qa with no deploy URL is blocked too.
658
+ const qaNeedsUrl = codes.has("deploy.preview_url") && !deploySatisfied;
659
+ // build is not runnable over a missing scaffold either (`next build`
660
+ // refuses with next.build.setup); the action list names the setup step.
661
+ // done is not ready while the runtime scope is partial: checkout launch
662
+ // and test orders are still owed, whatever the ladder's stages say.
663
+ const blocked = picked.stage === "doctor-blocked" || picked.stage === "prepare-build" || picked.blocked === true
664
+ || (picked.stage === "qa" && qaNeedsUrl)
665
+ || (picked.stage === "build" && derived.scaffold_required === true)
666
+ || (picked.stage === "done" && codes.has("scope.runtime_qa_blocked"));
667
+ // Stages behind the picked one. done has none; the two pre-ladder states
668
+ // block the whole ladder; a ladder stage blocks what follows it.
669
+ const later = picked.stage === "done"
670
+ ? []
671
+ : picked.stage === "doctor-blocked" || picked.stage === "prepare-build"
672
+ ? [...NEXT_STAGE_ORDER]
673
+ : NEXT_STAGE_ORDER.includes(picked.stage)
674
+ ? NEXT_STAGE_ORDER.slice(NEXT_STAGE_ORDER.indexOf(picked.stage) + 1)
675
+ : [];
676
+ // Scope markers that are not ladder stages but that readers key on: a
677
+ // partial runtime scope blocks checkout launch readiness and test orders
678
+ // whatever stage comes next, and unconfirmed allowed domains block the
679
+ // runtime SDK verification.
680
+ const scopeMarkers = [
681
+ ...(codes.has("scope.runtime_qa_blocked") ? ["checkout-launch-ready", "test-orders"] : []),
682
+ ...(codes.has("campaign.allowed_domains_confirmed") ? ["runtime-sdk-verification"] : []),
683
+ ];
684
+ const gateBlocked = [
685
+ ...(polishBlocked ? NEXT_STAGE_ORDER.slice(NEXT_STAGE_ORDER.indexOf("polish")) : []),
686
+ // QA cannot run against no URL: `next qa` refuses with next.qa.deploy_url.
687
+ ...(qaNeedsUrl ? ["qa"] : []),
688
+ ];
689
+ const owners = DOCTOR_NEXT_STAGE_OWNERS[picked.stage];
690
+ // prepare-build is not a `next <stage>` argument: the stage-less `next`
691
+ // is what prints the recovery actions for it, and it is also the right
692
+ // call after a doctor-blocked repair or at done.
693
+ const command = picked.stage === "doctor-blocked"
694
+ ? `${cmd("doctor")} --packet ${packetRef}`
695
+ : picked.stage === "prepare-build" || picked.stage === "done"
696
+ ? `${cmd("next")} --packet ${packetRef}`
697
+ : `${cmd("next")} ${picked.stage} --packet ${packetRef}`;
698
+ const fallbackAction = picked.stage === "done"
699
+ ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
700
+ : `Run ${command}.`;
701
+ return {
702
+ stage: picked.stage,
703
+ status: blocked ? "blocked" : readinessStatus(warnings, derived),
704
+ // The picker's own verdict on the picked stage (a blocked polish gate, a
705
+ // blocked ladder stage, prepare-build), as distinct from `status`, which
706
+ // also folds in what makes the stage unrunnable (no deploy URL, no
707
+ // scaffold). `next` reports it as its own stage_blocked.
708
+ stage_blocked: picked.blocked === true,
709
+ owner: owners.owner,
710
+ default_skill: owners.default_skill,
711
+ command,
712
+ reason: picked.reason,
713
+ actions: actions.length ? actions : [fallbackAction],
714
+ // Gate-blocked stages stay listed even when the recommended stage is
715
+ // runnable (a Design Source Package change after assembly names build,
716
+ // which is allowed, while polish, deploy and qa stay refused).
717
+ blocked_stages: [...new Set([...(blocked ? later : []), ...gateBlocked, ...scopeMarkers])]
718
+ .filter((stage) => stage !== picked.stage),
719
+ };
720
+ }
721
+
722
+ export {
723
+ polishGateRequiresBuild,
724
+ pushGateIssue,
725
+ nextPrepareBuildBindingIssues,
726
+ prepareBuildGateIssue,
727
+ addPrepareBuildGateErrors,
728
+ deployUrlFromReportOutputs,
729
+ checkpointExceptionPresent,
730
+ buildNextStep,
731
+ };