@nextcommerce/campaigns-os 1.50.0 → 1.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/cli.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
22
22
|
import { shellToken } from "./shell-token.mjs";
|
|
23
|
+
import { declaredOrderBumps, declaredSelectorTiers } from "./commercial-journey.mjs";
|
|
23
24
|
import { diagnosticExport, diagnosticTextLines } from "./diagnostic.mjs";
|
|
24
25
|
import { observeProgress, PROGRESS_OBSERVATION } from "./progress-node.mjs";
|
|
25
26
|
import { HIDDEN_EAGER_MEDIA_ACTIONS, requiredActionText, substitutePacket } from "./gate-actions.mjs";
|
|
@@ -75,7 +76,10 @@ import {
|
|
|
75
76
|
isWrapperPolicy,
|
|
76
77
|
} from "./adapter-decision-contract.mjs";
|
|
77
78
|
import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
78
|
-
import {
|
|
79
|
+
import { QcAcceptRefusal, buildQcHandoff, parseQcResultRef, planQcAccepts, projectQcAccept, qcAcceptAttribution, qcHandoffTextLines } from "./qc-accept.mjs";
|
|
80
|
+
import { loadQcRederivers } from "./qc-check-registry.mjs";
|
|
81
|
+
import { fingerprint12, readCurrentQcResults } from "./qc-results.mjs";
|
|
82
|
+
import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
79
83
|
import { canonicalPath, sameFile } from "./fs-identity.mjs";
|
|
80
84
|
import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
|
|
81
85
|
import { writeMapSdkPin } from "./map-pin-writeback.mjs";
|
|
@@ -116,9 +120,11 @@ import {
|
|
|
116
120
|
SOURCE_HTML_MANIFEST_REL_PATH,
|
|
117
121
|
} from "./source-html-manifest.mjs";
|
|
118
122
|
import { crawlSourceAssetPaths } from "./source-asset-crawl.mjs";
|
|
123
|
+
import { summarizeCampaignBrief } from "./brief-summary.mjs";
|
|
119
124
|
import {
|
|
120
125
|
THEME_POLICIES,
|
|
121
126
|
inspectBrandTheme,
|
|
127
|
+
themeIssueForReport,
|
|
122
128
|
writeThemeArtifacts,
|
|
123
129
|
} from "./brand-theme.mjs";
|
|
124
130
|
import {
|
|
@@ -144,6 +150,7 @@ import {
|
|
|
144
150
|
LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
|
|
145
151
|
LOCAL_PROOF_NEVER_EDIT_RULE,
|
|
146
152
|
LOCAL_PROOF_PARITY_COMMAND,
|
|
153
|
+
LOCAL_PROOF_RECORD_BUILD_COMMAND,
|
|
147
154
|
LOCAL_PROOF_PARITY_FIELD,
|
|
148
155
|
LOCAL_PROOF_PARITY_SCOPE,
|
|
149
156
|
recordedBuildEnvironment,
|
|
@@ -168,6 +175,8 @@ import {
|
|
|
168
175
|
createCampaignBuildBriefArtifact,
|
|
169
176
|
inferBuildBriefPath,
|
|
170
177
|
} from "./build-brief.mjs";
|
|
178
|
+
import { briefIntakeBindings, currentPacketInputs, inputRefreshCommands, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
179
|
+
import { prepareBuildStageStatus, priorBriefInputsForIntake } from "./input-refresh.mjs";
|
|
171
180
|
import {
|
|
172
181
|
DESIGN_SOURCE_PACKAGE_REL_PATH,
|
|
173
182
|
} from "./design-source-package.mjs";
|
|
@@ -194,7 +203,7 @@ import {
|
|
|
194
203
|
capturePolishPageLoad,
|
|
195
204
|
createPolishCaptureBinding,
|
|
196
205
|
evaluateRecordedHiddenEagerMediaCheckpoint,
|
|
197
|
-
|
|
206
|
+
mergePolishCaptureEvidence,
|
|
198
207
|
planPolishCapture,
|
|
199
208
|
} from "./polish-node.mjs";
|
|
200
209
|
import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
|
|
@@ -317,12 +326,15 @@ Usage:
|
|
|
317
326
|
campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
|
|
318
327
|
campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--dry-run] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused. --dry-run validates the same way and prints the waiver it would write, without touching the report
|
|
319
328
|
campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
|
|
329
|
+
campaigns-os checkpoint accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result ...] --reason "<the operator's reason>" --accepted-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # record the operator's accept of a measured QC warning, as the QC handoff of next prints it; changes no readiness and lapses when the measured state or the build changes. --dry-run runs every check and prints the accepts it would record, without touching the report
|
|
320
330
|
campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
|
|
321
331
|
campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
|
|
322
332
|
campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
|
|
323
333
|
campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
|
|
334
|
+
campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
335
|
+
campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
324
336
|
campaigns-os record setup --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # record setup complete once the campaign output directory exists: Build Context scaffold.required=false and stages.setup completed, validated against their schemas before either is written
|
|
325
|
-
campaigns-os record build --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
|
|
337
|
+
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (--build-environment records stages.assembly.evidence.build_environment; local proof mode records development): stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
|
|
326
338
|
campaigns-os record polish --packet <campaign-runtime.build.json> --evidence <polish-evidence.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after polish capture: stages.polish from the file's status (completed, completed_with_warnings, blocked with blockers, or skipped with skip_reason), evidence and optional repair_loop_defect, bound to doctor's current fingerprint; a completed status is refused, writing nothing, unless the polish gate doctor evaluates would pass. --dry-run runs every check and writes nothing
|
|
327
339
|
campaigns-os record theme --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after the brand layer is linked and build is recorded: report.theme becomes applied with load_order after-next-core, css_path, commerce_pages and per-page evidence, only when each built commerce page that loads next-core.css loads brand-theme.css (or checkout-brand.css) after it and at least one does; a page loading neither is left out as the design's own markup; refused, writing nothing, otherwise. --dry-run runs every check and writes nothing
|
|
328
340
|
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs; refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
|
|
@@ -458,9 +470,12 @@ function closestCommand(input) {
|
|
|
458
470
|
// invocation policy: src/invocation.mjs owns it and main() only hands over the
|
|
459
471
|
// mechanisms. The raw argv rides along for the handlers that must see it
|
|
460
472
|
// (authentication, and the two raw-token validators).
|
|
461
|
-
|
|
473
|
+
// `qcStandIns` is the in-process QC stand-in override the QC tests pass
|
|
474
|
+
// (src/qc-test-factories.mjs). The bin entry point never passes it, and no
|
|
475
|
+
// flag, environment variable or file can supply one.
|
|
476
|
+
export async function main(argv, { authentication, qcStandIns } = {}) {
|
|
462
477
|
return runInvocation(parseArgs(argv), {
|
|
463
|
-
dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication }),
|
|
478
|
+
dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
|
|
464
479
|
closeOutStaleRunSessions,
|
|
465
480
|
ambientRunSession,
|
|
466
481
|
lifecycleIdentity,
|
|
@@ -699,6 +714,10 @@ export function recordQaStageOutcome(args, result) {
|
|
|
699
714
|
const failed = (Array.isArray(verdict.assertions) ? verdict.assertions : [])
|
|
700
715
|
.filter((assertion) => assertion?.status === "fail")
|
|
701
716
|
.map((assertion) => `${assertion.id}: ${assertion.actual || "assertion failed"}`);
|
|
717
|
+
// The QA stamps are the verdict's: the brief material qa run bound at run
|
|
718
|
+
// start and the spec material it judged, never the inputs at write time.
|
|
719
|
+
const qaStamps = inputStamps({ briefMaterial: verdict.source_brief_material, specMaterial: verdict.spec_hash });
|
|
720
|
+
const qaInputs = stageWriteInputs("qa", currentPacketInputs({ packet, packetPath }));
|
|
702
721
|
const committed = commitAssemblyReport(workspace, (report) => {
|
|
703
722
|
if (hasLocalIdentity && !specHashesMatch(verdict.spec_hash, report.identity?.spec_material_hash)) {
|
|
704
723
|
throw new Error("Local-spec QA verdict belongs to a different material revision; report evidence was not changed.");
|
|
@@ -719,11 +738,13 @@ export function recordQaStageOutcome(args, result) {
|
|
|
719
738
|
// Which build this verdict judged, and the gates whose browser outcome
|
|
720
739
|
// the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
|
|
721
740
|
// gate that never ran is left out, so silence never reads as a pass.
|
|
722
|
-
evidence: qaStageGateEvidence(verdict, report),
|
|
741
|
+
evidence: qaStageGateEvidence(verdict, report, result),
|
|
723
742
|
// Counts-only: never order ids, refs, emails or URLs (see
|
|
724
743
|
// summarizePurchaseProof). This is what lets `next` tell a real purchase
|
|
725
744
|
// path from a `--test-order off` diagnostic.
|
|
726
745
|
proof: summarizePurchaseProof({ verdict, proofPolicy: packet.qa?.proof_policy }),
|
|
746
|
+
stamps: qaStamps,
|
|
747
|
+
inputs: qaInputs,
|
|
727
748
|
});
|
|
728
749
|
}, {
|
|
729
750
|
stage: "qa",
|
|
@@ -755,12 +776,21 @@ export function recordQaStageOutcome(args, result) {
|
|
|
755
776
|
}
|
|
756
777
|
}
|
|
757
778
|
|
|
758
|
-
|
|
779
|
+
// QC results are written on every QA record, with the build
|
|
780
|
+
// they were measured against, whether or not a gate outcome is recorded: a
|
|
781
|
+
// record without qc_results reads as captured by an earlier version. The rows
|
|
782
|
+
// come from the QA run's own result; readers re-derive them from the full
|
|
783
|
+
// verdict's qc.* assertions.
|
|
784
|
+
function qaStageGateEvidence(verdict, report, result = null) {
|
|
759
785
|
const gates = {};
|
|
760
786
|
const placeholderText = summarizePlaceholderTextGate(verdict);
|
|
761
787
|
if (placeholderText) gates[QA_GATE_PLACEHOLDER_TEXT_RESIDUE] = placeholderText;
|
|
762
|
-
|
|
763
|
-
|
|
788
|
+
const qc = {
|
|
789
|
+
qc_results: Array.isArray(result?.qc_results) ? result.qc_results : [],
|
|
790
|
+
qc_build_fingerprint: currentBuildFingerprint(report),
|
|
791
|
+
};
|
|
792
|
+
if (!Object.keys(gates).length) return qc;
|
|
793
|
+
return { source_build_fingerprint: currentBuildFingerprint(report), gates, ...qc };
|
|
764
794
|
}
|
|
765
795
|
|
|
766
796
|
// What the auto-end says when the attempt does NOT end the session. Every
|
|
@@ -892,7 +922,7 @@ const PREPARE_MODES = Object.freeze({
|
|
|
892
922
|
// no ambient session, and ahead of help routing, so `login --help`, `demo
|
|
893
923
|
// --help` and `tooling setup --help` reach their own handlers. `argv` is the
|
|
894
924
|
// unparsed argv, for the handlers that must see repeated tokens.
|
|
895
|
-
async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication } = {}) {
|
|
925
|
+
async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication, qcStandIns } = {}) {
|
|
896
926
|
// Authentication never recovers/remits run sessions or records argv in a
|
|
897
927
|
// lifecycle journal. Credentials belong only in the user credential store.
|
|
898
928
|
if (command === "login" || command === "logout") {
|
|
@@ -1000,7 +1030,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1000
1030
|
// The live campaign ref check's one read (#533), made before the
|
|
1001
1031
|
// synchronous inspection; see readDoctorLiveCampaign.
|
|
1002
1032
|
const liveCampaign = await readDoctorLiveCampaign(args);
|
|
1003
|
-
const result = doctorCommand(args, { liveCampaign });
|
|
1033
|
+
const result = doctorCommand(args, { liveCampaign, qcStandIns });
|
|
1004
1034
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1005
1035
|
printDoctorTinyPrompt(result, args);
|
|
1006
1036
|
return;
|
|
@@ -1055,6 +1085,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1055
1085
|
}
|
|
1056
1086
|
|
|
1057
1087
|
if (command === "checkpoint") {
|
|
1088
|
+
if (args._[1] === "accept") {
|
|
1089
|
+
const result = await checkpointAcceptCommand(args, { argv, qcStandIns });
|
|
1090
|
+
if (!result) return;
|
|
1091
|
+
writeResult(result, args, result.ok ? 0 : 2);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1058
1094
|
const result = waiveOrRefuse(args, () => checkpointCommand(args), {
|
|
1059
1095
|
gate: optionalString(args.gate) || null,
|
|
1060
1096
|
registeredGates: Object.keys(CHECKPOINT_EVALUATORS),
|
|
@@ -1154,7 +1190,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1154
1190
|
// stage from the current report + doctor state. Existing form with an
|
|
1155
1191
|
// explicit stage (`next build`, `next polish`, etc.) is unchanged.
|
|
1156
1192
|
const stage = args._[1] || null;
|
|
1157
|
-
|
|
1193
|
+
// The QC handoff re-derives QA and Polish results through the check
|
|
1194
|
+
// registry, whose check modules load lazily, so they are loaded here.
|
|
1195
|
+
const qcRederivers = await loadQcRederivers();
|
|
1196
|
+
const result = nextStage(stage, args, ambient, { qcStandIns, qcRederivers });
|
|
1158
1197
|
await observeProgress(args, result, { packageVersion: packageVersion(), resolveKey: resolveCampaignsApiKeySource });
|
|
1159
1198
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1160
1199
|
printNextTinyPrompt(result, args);
|
|
@@ -1410,8 +1449,7 @@ function createInitialAssemblyReportStages({ scaffoldRequired, blockers, outputs
|
|
|
1410
1449
|
// (STAGE_TERMINAL_STATUS_PREFIXES): declared out-of-scope pages do not hold
|
|
1411
1450
|
// the ladder, they are recorded on the stage so downstream consumers can see
|
|
1412
1451
|
// exactly which pages assemble from the template family instead of source HTML.
|
|
1413
|
-
|
|
1414
|
-
stages.prepare_build = createStage("prepare_build", blockers.length ? "blocked" : terminalStatus, {
|
|
1452
|
+
stages.prepare_build = createStage("prepare_build", prepareBuildStageStatus({ blockers, declaredScopeSkips }), {
|
|
1415
1453
|
outputs,
|
|
1416
1454
|
blockers,
|
|
1417
1455
|
...(declaredScopeSkips.length ? { declared_out_of_scope: declaredScopeSkips } : {}),
|
|
@@ -1762,6 +1800,10 @@ function prepareBuildUnderLock({
|
|
|
1762
1800
|
sourceRoot,
|
|
1763
1801
|
targetRepo,
|
|
1764
1802
|
});
|
|
1803
|
+
// The report and normalized brief this run replaces: the report's brief
|
|
1804
|
+
// mode keeps an existing prepared campaign prepared (when it is this
|
|
1805
|
+
// campaign's report), and the previous brief carries field provenance.
|
|
1806
|
+
const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
|
|
1765
1807
|
const buildBrief = createCampaignBuildBriefArtifact({
|
|
1766
1808
|
inputPath: briefDiscovery?.path || null,
|
|
1767
1809
|
inputSource: briefDiscovery?.source || null,
|
|
@@ -1771,10 +1813,19 @@ function prepareBuildUnderLock({
|
|
|
1771
1813
|
templateFamily,
|
|
1772
1814
|
sourceAssetCrawl,
|
|
1773
1815
|
commerceZoneFindings,
|
|
1816
|
+
boundReportMode: priorBriefInputs.boundReportMode,
|
|
1817
|
+
previousNormalizedBrief: priorBriefInputs.previousNormalizedBrief,
|
|
1774
1818
|
});
|
|
1775
1819
|
if (buildBrief.inputPath && buildBrief.artifact?._meta) {
|
|
1776
1820
|
buildBrief.artifact._meta.input_path = relFromFile(briefPath, buildBrief.inputPath);
|
|
1777
1821
|
}
|
|
1822
|
+
const briefBindings = briefIntakeBindings(buildBrief);
|
|
1823
|
+
// The packet settings this run writes, checked against the packet on disk
|
|
1824
|
+
// before anything is published.
|
|
1825
|
+
const deploySettings = {
|
|
1826
|
+
target: optionalString(args["deploy-target"], "unknown"),
|
|
1827
|
+
preview_url: optionalString(args["preview-url"]),
|
|
1828
|
+
};
|
|
1778
1829
|
const buildBriefPrompts = buildBrief.mode === "prepared" ? [] : buildBrief.questions.map((question) => ({
|
|
1779
1830
|
code: `BUILD_BRIEF_${toConstantCase(question.id)}`,
|
|
1780
1831
|
stage: "prepare_build",
|
|
@@ -1808,6 +1859,11 @@ function prepareBuildUnderLock({
|
|
|
1808
1859
|
}))
|
|
1809
1860
|
: [];
|
|
1810
1861
|
const designSourcePackage = publication.package({
|
|
1862
|
+
plannedPacket: {
|
|
1863
|
+
campaign: { allowed_domains_confirmed: args["allowed-domains-confirmed"] === true },
|
|
1864
|
+
deploy: { ...deploySettings, production_url: optionalString(args["production-url"]) },
|
|
1865
|
+
qa: { proof_policy: { order_path_depth: orderPathDepthFlag || "common" } },
|
|
1866
|
+
},
|
|
1811
1867
|
activePages,
|
|
1812
1868
|
mappings: matched.mappings,
|
|
1813
1869
|
manifestResult,
|
|
@@ -1859,6 +1915,7 @@ function prepareBuildUnderLock({
|
|
|
1859
1915
|
normalized_path: relFromFile(packetPath, briefPath),
|
|
1860
1916
|
question_count: buildBrief.questions.length,
|
|
1861
1917
|
gate_count: buildBrief.gates.length,
|
|
1918
|
+
...briefBindings,
|
|
1862
1919
|
},
|
|
1863
1920
|
assembly: {
|
|
1864
1921
|
implementation: "next-campaigns-build",
|
|
@@ -1886,14 +1943,13 @@ function prepareBuildUnderLock({
|
|
|
1886
1943
|
compatible_outputs: ["static-html", "campaign-cart-sdk"],
|
|
1887
1944
|
},
|
|
1888
1945
|
deploy: {
|
|
1889
|
-
|
|
1890
|
-
preview_url: optionalString(args["preview-url"]),
|
|
1946
|
+
...deploySettings,
|
|
1891
1947
|
production_url: optionalString(args["production-url"]),
|
|
1892
1948
|
live_url_path: liveUrlPath,
|
|
1893
1949
|
},
|
|
1894
1950
|
qa: {
|
|
1895
1951
|
proof_policy: proofPolicy,
|
|
1896
|
-
test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions. Run them any time with `qa run --test-order common`: when every actual terminal path in the selected checkout topology fits under the flood cap, common runs them all (effective depth `full`, reason `under_cap`); above the cap it runs checkout, first-offer accept/decline and a deduplicated shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, up to the cap, and names any page left out. The `browser-test-order:upsell-action-coverage` verdict row warns naming each offer page whose decline no executed order clicked. Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag: depth is the only control.",
|
|
1952
|
+
test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions. Run them any time with `qa run --test-order common`: when every actual terminal path in the selected checkout topology fits under the flood cap, common runs them all (effective depth `full`, reason `under_cap`); above the cap it runs checkout, first-offer accept/decline and a deduplicated shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, up to the cap, and names any page left out. The `browser-test-order:upsell-action-coverage` verdict row warns naming each offer page whose decline no executed order clicked. Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_order_bump` or `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag: depth is the only control.",
|
|
1897
1953
|
},
|
|
1898
1954
|
notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
|
|
1899
1955
|
};
|
|
@@ -1974,6 +2030,7 @@ function prepareBuildUnderLock({
|
|
|
1974
2030
|
gate_count: buildBrief.gates.length,
|
|
1975
2031
|
questions: buildBrief.questions,
|
|
1976
2032
|
gates: buildBrief.gates,
|
|
2033
|
+
...briefBindings,
|
|
1977
2034
|
},
|
|
1978
2035
|
page_map: matched.mappings.map((mapping) => ({
|
|
1979
2036
|
page_id: mapping.page_id,
|
|
@@ -2021,13 +2078,14 @@ function prepareBuildUnderLock({
|
|
|
2021
2078
|
if (!writtenTheme.ok && Array.isArray(writtenTheme.errors) && writtenTheme.errors.length > 0) {
|
|
2022
2079
|
context.theme.warnings = [
|
|
2023
2080
|
...(context.theme.warnings || []),
|
|
2024
|
-
...writtenTheme.errors.map(
|
|
2081
|
+
...writtenTheme.errors.map(themeIssueForReport),
|
|
2025
2082
|
];
|
|
2026
2083
|
}
|
|
2027
2084
|
|
|
2028
2085
|
// The top-level status/next/blockers are derived from the stages by the same
|
|
2029
2086
|
// function every later commit of the report runs (stage-ledger.mjs), so
|
|
2030
|
-
// prepare-build's first write and a producer's last write spell them alike
|
|
2087
|
+
// prepare-build's first write and a producer's last write spell them alike,
|
|
2088
|
+
// against the brief and CampaignSpec material this run binds.
|
|
2031
2089
|
const report = applyDerivedAssemblyReportSummary(createAssemblyReport({
|
|
2032
2090
|
packetPath,
|
|
2033
2091
|
contextPath,
|
|
@@ -2044,7 +2102,7 @@ function prepareBuildUnderLock({
|
|
|
2044
2102
|
buildScopeReasonsInvalid,
|
|
2045
2103
|
templateSelection,
|
|
2046
2104
|
evidence: strippedSpecBytes == null ? [] : hostStripped.evidence,
|
|
2047
|
-
}));
|
|
2105
|
+
}), { briefMaterial: briefBindings.material, specMaterial: specMaterialHash(spec) });
|
|
2048
2106
|
|
|
2049
2107
|
// Values a spec left as it is still holds that doctor blocks on. An
|
|
2050
2108
|
// absolute http(s) page_url is not among them: projection takes its path.
|
|
@@ -2615,7 +2673,7 @@ export function commitWaiverToAssemblyReport(workspace, mutate, options, { dryRu
|
|
|
2615
2673
|
// run's own "do not write" is this wrapper's null below, not the mutator's.
|
|
2616
2674
|
if (!isPlainObject(mutated)) throw new TypeError("commitAssemblyReport mutate(report) must return an Assembly Report object.");
|
|
2617
2675
|
if (!dryRun) return mutated;
|
|
2618
|
-
applyDerivedAssemblyReportSummary(mutated);
|
|
2676
|
+
applyDerivedAssemblyReportSummary(mutated, currentPacketInputs({ packet: workspace.packet, packetPath: workspace.packetPath }));
|
|
2619
2677
|
return null;
|
|
2620
2678
|
};
|
|
2621
2679
|
// A preview writes nothing, so it does not take the target lock either: the
|
|
@@ -2670,7 +2728,19 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2670
2728
|
throw new Error(`polish capture needs an existing Assembly Report at ${reportPath}; run prepare-build/start first.`);
|
|
2671
2729
|
}
|
|
2672
2730
|
requireValidPolishCaptureReport(report, reportPath);
|
|
2673
|
-
|
|
2731
|
+
// A campaign whose every mapping is template stock has no page_load route
|
|
2732
|
+
// to capture: its plan error is held, and the capture measures readability
|
|
2733
|
+
// on the built pages only, leaving page_load and media_weight as they are.
|
|
2734
|
+
const { capturePolishReadability, isNoCapturableRoutesError } = await import("./polish-node.mjs");
|
|
2735
|
+
const planOrHeld = (currentPacket) => {
|
|
2736
|
+
try {
|
|
2737
|
+
return { plan: planPolishCapture({ packet: currentPacket, baseUrl }), held: null };
|
|
2738
|
+
} catch (error) {
|
|
2739
|
+
if (!isNoCapturableRoutesError(error)) throw error;
|
|
2740
|
+
return { plan: null, held: error };
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
const { plan, held: heldPlanError } = planOrHeld(packet);
|
|
2674
2744
|
const initialBinding = createPolishCaptureBinding({
|
|
2675
2745
|
packet,
|
|
2676
2746
|
report,
|
|
@@ -2683,7 +2753,7 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2683
2753
|
if (typeof createBrowserAdapter !== "function") {
|
|
2684
2754
|
({ createPolishBrowserAdapter: createBrowserAdapter } = await import("./polish-browser.mjs"));
|
|
2685
2755
|
}
|
|
2686
|
-
const capture = await capturePolishPageLoad({
|
|
2756
|
+
const capture = await (heldPlanError ? capturePolishReadability : capturePolishPageLoad)({
|
|
2687
2757
|
packet,
|
|
2688
2758
|
report,
|
|
2689
2759
|
baseUrl,
|
|
@@ -2693,7 +2763,11 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2693
2763
|
adapterStartupDeadlineMs: options.adapterStartupDeadlineMs,
|
|
2694
2764
|
captureCellDeadlineMs: options.captureCellDeadlineMs,
|
|
2695
2765
|
adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
|
|
2766
|
+
probeClock: options.probeClock,
|
|
2767
|
+
targetRepo,
|
|
2696
2768
|
});
|
|
2769
|
+
// Nothing was measured: the held plan error stands.
|
|
2770
|
+
if (heldPlanError && !capture.readability) throw heldPlanError;
|
|
2697
2771
|
|
|
2698
2772
|
if (typeof options.afterCapture === "function") {
|
|
2699
2773
|
await options.afterCapture({ packetPath, reportPath, targetRepo, capture });
|
|
@@ -2706,7 +2780,7 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2706
2780
|
let checkpoint = null;
|
|
2707
2781
|
commitAssemblyReport(workspace, (currentReport) => {
|
|
2708
2782
|
requireValidPolishCaptureReport(currentReport, reportPath);
|
|
2709
|
-
const currentPlan =
|
|
2783
|
+
const { plan: currentPlan } = planOrHeld(currentPacket);
|
|
2710
2784
|
const currentBinding = createPolishCaptureBinding({
|
|
2711
2785
|
packet: currentPacket,
|
|
2712
2786
|
report: currentReport,
|
|
@@ -2716,7 +2790,12 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2716
2790
|
});
|
|
2717
2791
|
assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
|
|
2718
2792
|
|
|
2719
|
-
const merged =
|
|
2793
|
+
const merged = mergePolishCaptureEvidence(currentReport, {
|
|
2794
|
+
pageLoad: capture.page_load,
|
|
2795
|
+
mediaWeight: capture.media_weight,
|
|
2796
|
+
readability: capture.readability,
|
|
2797
|
+
now: options.now ?? new Date(),
|
|
2798
|
+
});
|
|
2720
2799
|
checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
|
|
2721
2800
|
packet: currentPacket,
|
|
2722
2801
|
report: merged,
|
|
@@ -2727,19 +2806,39 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2727
2806
|
staleReason: `Package-owned polish page-load evidence changed after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
2728
2807
|
});
|
|
2729
2808
|
|
|
2809
|
+
if (heldPlanError) {
|
|
2810
|
+
// The readability capture completed; page_load's own outcome stays the
|
|
2811
|
+
// held no_capturable_routes message, reported as a warning.
|
|
2812
|
+
return {
|
|
2813
|
+
ok: true,
|
|
2814
|
+
status: "captured_with_warnings",
|
|
2815
|
+
action: "polish-capture",
|
|
2816
|
+
report_path: reportPath,
|
|
2817
|
+
measurement: null,
|
|
2818
|
+
checkpoint,
|
|
2819
|
+
observed_findings: [],
|
|
2820
|
+
warnings: [{ code: heldPlanError.code, message: heldPlanError.message }],
|
|
2821
|
+
capture: {
|
|
2822
|
+
route_scope: null,
|
|
2823
|
+
routes: [],
|
|
2824
|
+
viewports: [],
|
|
2825
|
+
readability_routes: capture.readability?.subject?.routes ?? [],
|
|
2826
|
+
},
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2730
2829
|
const ok = checkpoint.status === "pass" || checkpoint.status === "waived";
|
|
2731
2830
|
return {
|
|
2732
2831
|
ok,
|
|
2733
2832
|
status: ok ? (checkpoint.status === "waived" ? "ready_with_waivers" : "ready") : "blocked",
|
|
2734
2833
|
action: "polish-capture",
|
|
2735
2834
|
report_path: reportPath,
|
|
2736
|
-
measurement: capture.page_load
|
|
2835
|
+
measurement: capture.page_load?.measurement ?? null,
|
|
2737
2836
|
checkpoint,
|
|
2738
|
-
observed_findings: capture.page_load
|
|
2837
|
+
observed_findings: capture.page_load?.findings ?? [],
|
|
2739
2838
|
capture: {
|
|
2740
|
-
route_scope: capture.plan
|
|
2741
|
-
routes: capture.plan
|
|
2742
|
-
viewports: capture.plan
|
|
2839
|
+
route_scope: capture.plan?.route_scope ?? null,
|
|
2840
|
+
routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
|
|
2841
|
+
viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
|
|
2743
2842
|
},
|
|
2744
2843
|
};
|
|
2745
2844
|
}
|
|
@@ -2811,7 +2910,7 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
|
|
|
2811
2910
|
function checkpointCommand(args) {
|
|
2812
2911
|
const subcommand = args._[1] || "help";
|
|
2813
2912
|
if (subcommand !== "waive") {
|
|
2814
|
-
throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
|
|
2913
|
+
throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}. Or: ${cmd("checkpoint")} accept --packet <campaign-runtime.build.json> --result <result_id>@<fingerprint12> [--result …] --reason "<the operator's reason>" --accepted-by "<operator's name>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run].`);
|
|
2815
2914
|
}
|
|
2816
2915
|
return checkpointWaive(args);
|
|
2817
2916
|
}
|
|
@@ -2934,6 +3033,170 @@ export function checkpointWaive(args) {
|
|
|
2934
3033
|
};
|
|
2935
3034
|
}
|
|
2936
3035
|
|
|
3036
|
+
// `checkpoint accept`: records an operator's accept of a
|
|
3037
|
+
// measured QC warning in report.qc_accepts[], beside the unchanged
|
|
3038
|
+
// measurement. It reads only what is already on disk (doctor recomputed
|
|
3039
|
+
// offline, the persisted doctor sidecar, the Assembly Report and the full QA
|
|
3040
|
+
// verdict the QA stage names) and sends nothing. Every listed result must be a
|
|
3041
|
+
// current accept-eligible warning that was on record before this command;
|
|
3042
|
+
// the check runs again under the report lock immediately before the one
|
|
3043
|
+
// write, and if any ref fails nothing is written.
|
|
3044
|
+
const CHECKPOINT_ACCEPT_VALUE_FLAGS = Object.freeze({
|
|
3045
|
+
packet: "<campaign-runtime.build.json>",
|
|
3046
|
+
result: "<result_id>@<fingerprint12>",
|
|
3047
|
+
reason: "\"<the operator's reason>\"",
|
|
3048
|
+
"expires-at": "<ISO>",
|
|
3049
|
+
"review-condition": "\"<trigger>\"",
|
|
3050
|
+
report: "<json>",
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
// Every value of a flag the operator may repeat, from the raw argv: the shared
|
|
3054
|
+
// parser keeps only the last.
|
|
3055
|
+
function repeatedFlagValues(argv, flag) {
|
|
3056
|
+
const values = [];
|
|
3057
|
+
const tokens = Array.isArray(argv) ? argv : [];
|
|
3058
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
3059
|
+
if (tokens[index] !== `--${flag}`) continue;
|
|
3060
|
+
const next = tokens[index + 1];
|
|
3061
|
+
values.push(next === undefined || next.startsWith("--") ? null : next);
|
|
3062
|
+
}
|
|
3063
|
+
return values;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function qcAcceptRefusalEnvelope(error) {
|
|
3067
|
+
return {
|
|
3068
|
+
ok: false,
|
|
3069
|
+
refusal_code: error.code,
|
|
3070
|
+
error: error.message,
|
|
3071
|
+
...(error.refused?.length ? { refused: error.refused.map(({ result_ref, refusal_code }) => ({ result_ref, refusal_code })) } : {}),
|
|
3072
|
+
};
|
|
3073
|
+
}
|
|
3074
|
+
|
|
3075
|
+
export async function checkpointAcceptCommand(args, { argv = [], qcStandIns = null } = {}) {
|
|
3076
|
+
try {
|
|
3077
|
+
const qcRederivers = await loadQcRederivers();
|
|
3078
|
+
return checkpointAccept(args, { argv, qcStandIns, qcRederivers });
|
|
3079
|
+
} catch (error) {
|
|
3080
|
+
// A typed refusal is an invocation refusal like every untyped one
|
|
3081
|
+
// (refused() marks this invocation's refusal scope), so a refused accept
|
|
3082
|
+
// journals nothing, rendered under --json or thrown without it.
|
|
3083
|
+
const refusal = error instanceof QcAcceptRefusal ? refused(`${error.message} (${error.code})`) : error;
|
|
3084
|
+
if (args.json !== true) throw refusal;
|
|
3085
|
+
const envelope = error instanceof QcAcceptRefusal ? qcAcceptRefusalEnvelope(error) : { ok: false, error: String(error?.message ?? error) };
|
|
3086
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
3087
|
+
console.error(`campaigns-os: ${envelope.error}`);
|
|
3088
|
+
process.exitCode = 1;
|
|
3089
|
+
return null;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
export function checkpointAccept(args, { argv = [], qcStandIns = null, qcRederivers = null } = {}) {
|
|
3094
|
+
// The command clock; no flag sets accepted_at.
|
|
3095
|
+
const now = new Date().toISOString();
|
|
3096
|
+
const acceptedByValues = repeatedFlagValues(argv, "accepted-by");
|
|
3097
|
+
const acceptedBy = acceptedByValues.length === 1 && isNonEmptyString(args["accepted-by"]) ? args["accepted-by"] : null;
|
|
3098
|
+
if (acceptedByValues.length > 1) throw refused("checkpoint accept takes one --accepted-by: one operator decides one accept command.");
|
|
3099
|
+
for (const [key, placeholder] of Object.entries(CHECKPOINT_ACCEPT_VALUE_FLAGS)) {
|
|
3100
|
+
if (!Object.hasOwn(args, key) || args[key] == null) continue;
|
|
3101
|
+
if (!isNonEmptyString(args[key])) throw refused(`--${key} needs a value: pass --${key} ${placeholder}.`);
|
|
3102
|
+
}
|
|
3103
|
+
if (repeatedFlagValues(argv, "reason").length > 1) throw refused("checkpoint accept takes one --reason per invocation: the operator's reason, verbatim.");
|
|
3104
|
+
const packetPath = resolve(requireArg(args, "packet"));
|
|
3105
|
+
const dryRun = isDryRun(args);
|
|
3106
|
+
const refValues = repeatedFlagValues(argv, "result");
|
|
3107
|
+
if (!refValues.length) throw refused(`checkpoint accept needs at least one --result <result_id>@<fingerprint12>, as the QC handoff of \`${cmd("next")}\` prints it.`);
|
|
3108
|
+
const refs = refValues.map((value) => {
|
|
3109
|
+
const parsed = parseQcResultRef(value);
|
|
3110
|
+
if (!parsed) throw refused(`--result ${JSON.stringify(value)} is not <result_id>@<fingerprint12>; copy the ref the QC handoff of \`${cmd("next")}\` prints.`);
|
|
3111
|
+
return parsed;
|
|
3112
|
+
});
|
|
3113
|
+
const duplicate = refs.find((ref, index) => refs.findIndex((other) => other.id === ref.id) !== index);
|
|
3114
|
+
if (duplicate) throw refused(`--result names "${duplicate.id}" more than once; list each result once.`);
|
|
3115
|
+
const attribution = qcAcceptAttribution({
|
|
3116
|
+
reason: args.reason,
|
|
3117
|
+
acceptedBy,
|
|
3118
|
+
now,
|
|
3119
|
+
expiresAt: args["expires-at"] ?? null,
|
|
3120
|
+
reviewCondition: args["review-condition"] ?? null,
|
|
3121
|
+
});
|
|
3122
|
+
|
|
3123
|
+
const packet = readJson(packetPath);
|
|
3124
|
+
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
3125
|
+
packet,
|
|
3126
|
+
reportPath: args.report == null ? undefined : resolve(args.report),
|
|
3127
|
+
followContextPointer: false,
|
|
3128
|
+
});
|
|
3129
|
+
const { reportPath, targetRepo, doctorOutPath } = workspace;
|
|
3130
|
+
if (!existsSync(reportPath)) throw refused(`checkpoint accept needs an assembly report at ${reportPath}; run prepare-build/start first.`);
|
|
3131
|
+
// An accept only appends. A qc_accepts or evidence value that is not an
|
|
3132
|
+
// array is refused, never replaced, so the value already there stays as it
|
|
3133
|
+
// is (checked on the read before the lock and again under it).
|
|
3134
|
+
const refuseUnappendable = (report) => {
|
|
3135
|
+
for (const field of ["qc_accepts", "evidence"]) {
|
|
3136
|
+
if (isPlainObject(report) && Object.hasOwn(report, field) && !Array.isArray(report[field])) {
|
|
3137
|
+
throw refused(`checkpoint accept refused; nothing was written: report.${field} in ${reportPath} is not an array, and an accept only appends to it. It was left as it is; repair it before accepting.`);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
};
|
|
3141
|
+
|
|
3142
|
+
// Doctor is recomputed offline (no live refs), the spec read the way `next`
|
|
3143
|
+
// reads it, and every leg's results taken from its reader site.
|
|
3144
|
+
const plan = (report) => {
|
|
3145
|
+
const doctor = doctorPacket(packetPath, { reportPath, ...(qcStandIns ? { qcStandIns } : {}) });
|
|
3146
|
+
let spec = null;
|
|
3147
|
+
try {
|
|
3148
|
+
spec = readJsonIfExists(doctor.derived?.spec_path || null);
|
|
3149
|
+
} catch {
|
|
3150
|
+
spec = null;
|
|
3151
|
+
}
|
|
3152
|
+
const { results } = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
|
|
3153
|
+
return planQcAccepts({ refs, results, sidecarPath: doctorOutPath, now, attribution });
|
|
3154
|
+
};
|
|
3155
|
+
// Checked once before taking the lock, so a refusal touches nothing, and
|
|
3156
|
+
// again on the report read under the lock, immediately before the write.
|
|
3157
|
+
const unlocked = readJson(reportPath);
|
|
3158
|
+
refuseUnappendable(unlocked);
|
|
3159
|
+
plan(unlocked);
|
|
3160
|
+
let records = [];
|
|
3161
|
+
const recordAccepts = (report) => {
|
|
3162
|
+
refuseUnappendable(report);
|
|
3163
|
+
records = plan(report);
|
|
3164
|
+
return {
|
|
3165
|
+
...report,
|
|
3166
|
+
qc_accepts: [...(report.qc_accepts ?? []), ...records],
|
|
3167
|
+
evidence: [
|
|
3168
|
+
...(report.evidence ?? []),
|
|
3169
|
+
...records.map((record) => `QC accept: ${record.result_id}@${fingerprint12(record.state_fingerprint)} (${record.leg}) accepted by ${record.accepted_by} at ${record.accepted_at}: ${record.reason}`),
|
|
3170
|
+
],
|
|
3171
|
+
};
|
|
3172
|
+
};
|
|
3173
|
+
commitWaiverToAssemblyReport(workspace, recordAccepts, {
|
|
3174
|
+
command: "checkpoint accept",
|
|
3175
|
+
staleReason: `A QC accept was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3176
|
+
}, { dryRun });
|
|
3177
|
+
const accepts = records.map(projectQcAccept);
|
|
3178
|
+
if (dryRun) {
|
|
3179
|
+
return {
|
|
3180
|
+
ok: true,
|
|
3181
|
+
status: "dry_run",
|
|
3182
|
+
dry_run: true,
|
|
3183
|
+
action: "checkpoint-accept",
|
|
3184
|
+
accepts,
|
|
3185
|
+
report_path: reportPath,
|
|
3186
|
+
would_write: reportPath,
|
|
3187
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record these accepts.",
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
return {
|
|
3191
|
+
ok: true,
|
|
3192
|
+
status: "recorded",
|
|
3193
|
+
action: "checkpoint-accept",
|
|
3194
|
+
accepts,
|
|
3195
|
+
report_path: reportPath,
|
|
3196
|
+
note: "Each accept changes only the result's disposition to operator_accepted. The warning, doctor status, next status and QA disposition are unchanged, and the accept lapses when the measured state or the build changes.",
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
|
|
2937
3200
|
// `page-kit sync`: write the CampaignSpec's Store Profile fields and SDK pin
|
|
2938
3201
|
// into the target's _data/campaigns.json entry for the packet's route. This is
|
|
2939
3202
|
// the recovery the two page-kit gates name: a fresh scaffold seeds the entry
|
|
@@ -3317,7 +3580,6 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
3317
3580
|
store: storeFlags
|
|
3318
3581
|
? { subdomain: storeFlags.subdomain, admin_api: adminApiBaseForStore(storeFlags.subdomain), ...(storeFlags.token_env ? {} : { transport: "gateway", endpoint: "https://mcp.nextcommerce.com/admin/" }), token_source: storeFlags.token_env ? `env:${storeFlags.token_env}` : "gateway:login", store_read: null, pages_read: null, primary_domain: null }
|
|
3319
3582
|
: null,
|
|
3320
|
-
rebound: { build_context: null, assembly_report: null },
|
|
3321
3583
|
errors: [],
|
|
3322
3584
|
warnings: [],
|
|
3323
3585
|
next: `${cmd("doctor")} --packet ${shellToken(packetPath)}`,
|
|
@@ -3551,17 +3813,15 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
3551
3813
|
addIssue(result.warnings, "spec.derive.projection_stale", `A page route ${dryRun ? "would move" : "moved"}, and the packet's page-kit projection (source_html.pages[].page_kit) and the Build Context were prepared from the old routes. Re-run ${cmd("prepare-build")} (or start) before the next build so they describe the routes the spec carries.`);
|
|
3552
3814
|
}
|
|
3553
3815
|
if (stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
3554
|
-
addIssue(result.warnings, "spec.derive.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}) rendered from the spec ${dryRun ? "this would rewrite" : "just rewritten"}.
|
|
3555
|
-
result.next = `${cmd("doctor")} --packet ${shellToken(packetPath)}, then rebuild: set stages.assembly.status back to "pending" on the Assembly Report and run ${cmd("next")} --packet ${shellToken(packetPath)}`;
|
|
3816
|
+
addIssue(result.warnings, "spec.derive.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}) rendered from the spec ${dryRun ? "this would rewrite" : "just rewritten"}. Bind the rewritten spec with ${cmd("record")} spec (it keeps stage history); next then routes back to the build, and Polish and QA are owed again.`);
|
|
3556
3817
|
}
|
|
3818
|
+
// spec derive writes the spec alone: the Build Context and the Assembly
|
|
3819
|
+
// Report keep the identity they bound, so every recorded stage reads its
|
|
3820
|
+
// stamps against the new content and `record spec` is what binds it.
|
|
3821
|
+
if (report) result.next = `${cmd("record")} spec --packet ${shellToken(packetPath)} ${dryRun ? "after the write " : ""}to bind the derived spec (it keeps stage history), then ${cmd("next")} --packet ${shellToken(packetPath)}`;
|
|
3557
3822
|
}
|
|
3558
3823
|
|
|
3559
3824
|
if (plan.changes.length && !dryRun) {
|
|
3560
|
-
// The identity the sidecars bound to the spec BEFORE this write, so the
|
|
3561
|
-
// re-bind below can tell "bound to the spec being replaced" from "already
|
|
3562
|
-
// drifted" and only ever moves the former.
|
|
3563
|
-
const beforeRawHash = createHash("sha256").update(text).digest("hex");
|
|
3564
|
-
const beforeMaterialHash = specMaterialHash(spec);
|
|
3565
3825
|
// The plan already refused every path its containers cannot take, so a
|
|
3566
3826
|
// throw here is a defect in this toolkit rather than in the spec; it is
|
|
3567
3827
|
// still a structured error, never a crash past the --json contract.
|
|
@@ -3599,82 +3859,7 @@ export function specDeriveCommand(args, { store: storeRead = null } = {}) {
|
|
|
3599
3859
|
rmSync(tmpPath, { force: true });
|
|
3600
3860
|
}
|
|
3601
3861
|
result.written = true;
|
|
3602
|
-
// The Build Context and the Assembly Report carry the spec's identity
|
|
3603
|
-
// (raw and material hashes) from prepare-build, and QA's verdict is
|
|
3604
|
-
// correlated against the material hash by the bundle check. Each sidecar
|
|
3605
|
-
// that was bound to the spec just replaced is re-bound to the new one;
|
|
3606
|
-
// one that already carried another identity is left as it is and named.
|
|
3607
|
-
const afterRawHash = createHash("sha256").update(serialized).digest("hex");
|
|
3608
|
-
const afterMaterialHash = specMaterialHash(spec);
|
|
3609
|
-
const boundToOld = (raw, material) => raw === beforeRawHash || material === beforeMaterialHash;
|
|
3610
|
-
// A sidecar is re-bound only when it names the spec being written: two
|
|
3611
|
-
// packets sharing a target repo can carry byte-identical spec exports,
|
|
3612
|
-
// and a matching hash alone would let one packet's derive re-bind the
|
|
3613
|
-
// other's sidecar to a spec it never used.
|
|
3614
|
-
const namesThisSpec = (recorded) => {
|
|
3615
|
-
if (!isNonEmptyString(recorded)) return false;
|
|
3616
|
-
try {
|
|
3617
|
-
return realpathSync(resolve(targetRepo, recorded)) === realSpecPath;
|
|
3618
|
-
} catch {
|
|
3619
|
-
return false;
|
|
3620
|
-
}
|
|
3621
|
-
};
|
|
3622
|
-
const contextPath = workspace?.contextPath || null;
|
|
3623
|
-
let context = null;
|
|
3624
|
-
try {
|
|
3625
|
-
context = contextPath ? readJsonIfExists(contextPath) : null;
|
|
3626
|
-
} catch (error) {
|
|
3627
|
-
result.rebound.build_context = false;
|
|
3628
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context could not be read (${singleLineDetail(error.message)}); its spec identity was not updated. Re-run prepare-build before QA so the bundle correlates.`);
|
|
3629
|
-
}
|
|
3630
|
-
if (isObject(context?.spec)) {
|
|
3631
|
-
if (!namesThisSpec(context.spec.path)) {
|
|
3632
|
-
result.rebound.build_context = false;
|
|
3633
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Build Context names a different spec file than the one derive wrote (another packet's, or a moved export); it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
|
|
3634
|
-
} else if (boundToOld(context.spec.hash, context.spec.material_hash)) {
|
|
3635
|
-
try {
|
|
3636
|
-
writeJsonAtomic(contextPath, { ...context, spec: { ...context.spec, hash: afterRawHash, material_hash: afterMaterialHash } });
|
|
3637
|
-
result.rebound.build_context = true;
|
|
3638
|
-
} catch (error) {
|
|
3639
|
-
result.rebound.build_context = false;
|
|
3640
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context's spec identity could not be updated (${singleLineDetail(error.message)}); re-run prepare-build before QA so the bundle correlates.`);
|
|
3641
|
-
}
|
|
3642
|
-
} else {
|
|
3643
|
-
result.rebound.build_context = false;
|
|
3644
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Build Context's spec identity was already bound to a different spec than the one derive replaced; it was left as it is. Re-run prepare-build before QA so the bundle correlates.`);
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
if (report && isObject(report.identity) && workspace) {
|
|
3648
|
-
if (!namesThisSpec(report.inputs?.spec_path)) {
|
|
3649
|
-
result.rebound.assembly_report = false;
|
|
3650
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Assembly Report names a different spec file than the one derive wrote (another packet's, or a moved export); it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
|
|
3651
|
-
} else if (boundToOld(report.identity.spec_hash, report.identity.spec_material_hash)) {
|
|
3652
|
-
try {
|
|
3653
|
-
// The identity is re-checked on the report as it is re-read for
|
|
3654
|
-
// the commit, so a prepare-build that re-bound it in the meantime
|
|
3655
|
-
// is left alone.
|
|
3656
|
-
const committed = commitAssemblyReport(workspace, (current) => (
|
|
3657
|
-
isObject(current.identity) && boundToOld(current.identity.spec_hash, current.identity.spec_material_hash)
|
|
3658
|
-
? { ...current, identity: { ...current.identity, spec_hash: afterRawHash, spec_material_hash: afterMaterialHash } }
|
|
3659
|
-
: null
|
|
3660
|
-
), {
|
|
3661
|
-
command: "spec derive",
|
|
3662
|
-
staleReason: `spec derive rewrote the CampaignSpec after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3663
|
-
});
|
|
3664
|
-
result.rebound.assembly_report = committed.written;
|
|
3665
|
-
if (!committed.written) addIssue(result.warnings, "spec.derive.identity_not_rebound", "The Assembly Report's spec identity moved while spec derive was running; it was left as it is. Re-run prepare-build before QA so the bundle correlates.");
|
|
3666
|
-
} catch (error) {
|
|
3667
|
-
result.rebound.assembly_report = false;
|
|
3668
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Assembly Report's spec identity could not be updated (${singleLineDetail(error.message)}); re-run prepare-build before QA so the bundle correlates.`);
|
|
3669
|
-
}
|
|
3670
|
-
} else {
|
|
3671
|
-
result.rebound.assembly_report = false;
|
|
3672
|
-
addIssue(result.warnings, "spec.derive.identity_not_rebound", `The Assembly Report's spec identity was already bound to a different spec than the one derive replaced; it was left as it is. Re-run prepare-build before QA so the bundle correlates.`);
|
|
3673
|
-
}
|
|
3674
|
-
}
|
|
3675
3862
|
// The retained doctor snapshot (if any) now predates the spec it judged.
|
|
3676
|
-
// commitAssemblyReport stamps it when the report was re-bound; every
|
|
3677
|
-
// other path stamps it here.
|
|
3678
3863
|
try {
|
|
3679
3864
|
markDoctorSidecarStale(targetRepo, {
|
|
3680
3865
|
command: "spec derive",
|
|
@@ -3810,8 +3995,8 @@ export function pageKitParityCommand(args, options = {}) {
|
|
|
3810
3995
|
const environment = recordedBuildEnvironment(report);
|
|
3811
3996
|
if (environment !== LOCAL_PROOF_BUILD_ENVIRONMENT) {
|
|
3812
3997
|
addIssue(result.warnings, LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE, environment
|
|
3813
|
-
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}", not "${LOCAL_PROOF_BUILD_ENVIRONMENT}". If _site/ is a production build, the comparison below fails as proven_output_stale on the first environment-gated line; rebuild with ${LOCAL_PROOF_BUILD_COMMAND} and record
|
|
3814
|
-
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded. The build stage under local-serve renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records it
|
|
3998
|
+
? `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is "${singleLineField(environment)}", not "${LOCAL_PROOF_BUILD_ENVIRONMENT}". If _site/ is a production build, the comparison below fails as proven_output_stale on the first environment-gated line; rebuild with ${LOCAL_PROOF_BUILD_COMMAND} and record it with ${asInvocation(substitutePacket(LOCAL_PROOF_RECORD_BUILD_COMMAND, packetPath))}.`
|
|
3999
|
+
: `${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} is not recorded. The build stage under local-serve renders the development environment (${LOCAL_PROOF_BUILD_COMMAND}) and records it with ${asInvocation(substitutePacket(LOCAL_PROOF_RECORD_BUILD_COMMAND, packetPath))}; the comparison below assumes _site/ is that render.`);
|
|
3815
4000
|
}
|
|
3816
4001
|
const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
|
|
3817
4002
|
const expectedSdkVersion = load.status === "ok" && typeof load.entry?.sdk_version === "string" ? load.entry.sdk_version : null;
|
|
@@ -4038,7 +4223,7 @@ function describeDeclaredDepth(purchaseProof) {
|
|
|
4038
4223
|
return `The declared order path depth is "${purchaseProof?.declared_depth || "unspecified"}"`;
|
|
4039
4224
|
}
|
|
4040
4225
|
|
|
4041
|
-
export function nextStage(stage, args, ambient = null) {
|
|
4226
|
+
export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRederivers = null } = {}) {
|
|
4042
4227
|
if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
|
|
4043
4228
|
throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
|
|
4044
4229
|
}
|
|
@@ -4053,6 +4238,8 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4053
4238
|
followContextPointer: true,
|
|
4054
4239
|
});
|
|
4055
4240
|
const report = readJsonIfExists(reportPath);
|
|
4241
|
+
// The campaign intent summary, computed once per call and only printed.
|
|
4242
|
+
const intentSummary = readIntentSummary(packetPath, packet, contextPath);
|
|
4056
4243
|
// Doctor reads the same sidecars through the same resolver (an operator's
|
|
4057
4244
|
// explicit --context / --report passed through, the defaults derived), so
|
|
4058
4245
|
// its prepare-build gate and its stage pick are this command's: `next`
|
|
@@ -4060,8 +4247,19 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4060
4247
|
const doctor = doctorPacket(packetPath, {
|
|
4061
4248
|
contextPath: args.context ? resolve(args.context) : undefined,
|
|
4062
4249
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
4250
|
+
...(qcStandIns ? { qcStandIns } : {}),
|
|
4063
4251
|
});
|
|
4064
4252
|
const prepareBuildGate = doctor.derived?.prepare_build_gate || null;
|
|
4253
|
+
// The CampaignSpec doctor just read (spec.local_path), for the QA stage's
|
|
4254
|
+
// order-bump command. Best-effort: doctor reports an unreadable spec itself.
|
|
4255
|
+
let spec = null;
|
|
4256
|
+
let specReadError = null;
|
|
4257
|
+
try {
|
|
4258
|
+
spec = readJsonIfExists(doctor.derived?.spec_path || null);
|
|
4259
|
+
} catch (error) {
|
|
4260
|
+
spec = null;
|
|
4261
|
+
specReadError = error;
|
|
4262
|
+
}
|
|
4065
4263
|
// #171: `next` recomputes doctor state on every call; persist that fresh
|
|
4066
4264
|
// snapshot so the retained sidecar can never stay a green lie from an
|
|
4067
4265
|
// earlier stage while the campaign degrades (the dogfood target sat
|
|
@@ -4127,13 +4325,31 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4127
4325
|
} });
|
|
4128
4326
|
if (divergences.length) result.divergences = divergences;
|
|
4129
4327
|
result.gates = buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate, packetPath });
|
|
4130
|
-
|
|
4328
|
+
if (doctor.derived?.input_currency) result.input_currency = doctor.derived.input_currency;
|
|
4329
|
+
result.next_actions = buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout, purchaseProof, brandContract: doctor.derived?.brand_contract || null, context: readJsonIfExists(contextPath), targetRepo, spec });
|
|
4330
|
+
// The QC handoff: read from data already loaded plus the
|
|
4331
|
+
// full QA verdict the QA stage names. It adds nothing to errors[],
|
|
4332
|
+
// warnings[] or ready[], and `status` above never reads it. Its accept
|
|
4333
|
+
// command names the report this run read when that is not the default
|
|
4334
|
+
// one (from --report or the Build Context pointer), so it records there.
|
|
4335
|
+
const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
|
|
4336
|
+
result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
|
|
4337
|
+
// One note per operator decision that kept the build output unchanged
|
|
4338
|
+
// after an input change, quoting the operator's reason.
|
|
4339
|
+
for (const issue of doctor.warnings) if (issue.code === "assembly.output_unchanged_by_operator_decision") (result.qc_handoff.notes ||= []).push(`Build output kept unchanged after an input change by the operator's decision: "${issue.detail.reason}"`);
|
|
4131
4340
|
recordNextRecommendation(ambient, result);
|
|
4341
|
+
result.intent_summary = intentSummary;
|
|
4132
4342
|
return result;
|
|
4133
4343
|
};
|
|
4134
4344
|
const doctorHasOnlyPolishGateErrors = doctorErrorsAreOnlyPolishGate(doctor.errors);
|
|
4135
4345
|
const errors = [];
|
|
4136
4346
|
const warnings = doctor.warnings.map((issue) => withPacketSubstitutedIssue(issue, packetPath));
|
|
4347
|
+
if (specReadError) {
|
|
4348
|
+
warnings.push({
|
|
4349
|
+
code: "next.order_bump_spec_unreadable",
|
|
4350
|
+
message: `The CampaignSpec at ${doctor.derived?.spec_path} could not be read (${singleLineField(specReadError.message)}), so next cannot tell whether the checkout declares an order bump and names no order-bump QA command. Fix the spec and run next again.`,
|
|
4351
|
+
});
|
|
4352
|
+
}
|
|
4137
4353
|
const ready = [...doctor.ready];
|
|
4138
4354
|
if (!doctor.ok && !doctorHasOnlyPolishGateErrors) errors.push(...doctor.errors.map((issue) => withPacketSubstitutedIssue(issue, packetPath)));
|
|
4139
4355
|
|
|
@@ -4214,12 +4430,12 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4214
4430
|
if (stage === "setup") {
|
|
4215
4431
|
addPrepareBuildGateErrors(errors, report);
|
|
4216
4432
|
if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.setup.doctor", "Doctor is blocked; resolve packet errors before setup.");
|
|
4217
|
-
prompt = setupPrompt(packetPath, contextPath, reportPath, packet);
|
|
4433
|
+
prompt = setupPrompt(packetPath, contextPath, reportPath, packet, intentSummary);
|
|
4218
4434
|
} else if (stage === "build") {
|
|
4219
4435
|
addPrepareBuildGateErrors(errors, report);
|
|
4220
4436
|
if (!doctor.ok && !doctorHasOnlyPolishGateErrors) addIssue(errors, "next.build.doctor", "Doctor is blocked; resolve packet errors before build.");
|
|
4221
4437
|
if (doctor.derived?.scaffold_required) addIssue(errors, "next.build.setup", doctor.derived.scaffold_reason || "Setup is required before build.");
|
|
4222
|
-
prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived);
|
|
4438
|
+
prompt = buildPrompt(packetPath, contextPath, reportPath, packet, doctor.derived, intentSummary);
|
|
4223
4439
|
} else if (stage === "polish") {
|
|
4224
4440
|
addPrepareBuildGateErrors(errors, report);
|
|
4225
4441
|
if (!report) addIssue(errors, "next.polish.report", "Assembly report is required before polish.");
|
|
@@ -4227,7 +4443,7 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4227
4443
|
if (!assemblyStatus.startsWith("completed")) addIssue(errors, "next.polish.assembly", `Assembly status is "${assemblyStatus || "missing"}"; polish expects completed assembly or an explicit blocked/skipped handoff.`);
|
|
4228
4444
|
if (polishGateRequiresBuild(polishGate)) addPolishGateErrors(errors, polishGate, "polish");
|
|
4229
4445
|
addThemeGateErrors(errors, themeGate, "polish");
|
|
4230
|
-
prompt = polishPrompt(packetPath, reportPath, packet);
|
|
4446
|
+
prompt = polishPrompt(packetPath, reportPath, packet, intentSummary);
|
|
4231
4447
|
} else if (stage === "deploy") {
|
|
4232
4448
|
addPrepareBuildGateErrors(errors, report);
|
|
4233
4449
|
// Slice 3 Phase 2: deploy is an out-of-band step (Netlify / CF Pages /
|
|
@@ -4246,7 +4462,7 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4246
4462
|
addPolishGateErrors(errors, polishGate, "qa");
|
|
4247
4463
|
addPolishCheckpointGateErrors(errors, polishCheckpointGate, "qa");
|
|
4248
4464
|
addThemeGateErrors(errors, themeGate, "qa");
|
|
4249
|
-
prompt = qaPrompt(packetPath, reportPath, packet);
|
|
4465
|
+
prompt = qaPrompt(packetPath, reportPath, packet, spec, intentSummary);
|
|
4250
4466
|
}
|
|
4251
4467
|
const status = errors.length
|
|
4252
4468
|
? "blocked"
|
|
@@ -4513,7 +4729,7 @@ function divergenceInspectAction(divergences, packetPath) {
|
|
|
4513
4729
|
|
|
4514
4730
|
// Executable next actions: exact commands (or explicitly-manual steps), never
|
|
4515
4731
|
// prose-only guidance. Ordering is the execution order an agent should follow.
|
|
4516
|
-
export function buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout = null, purchaseProof = null, brandContract = null, context = null, targetRepo = null }) {
|
|
4732
|
+
export function buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout = null, purchaseProof = null, brandContract = null, context = null, targetRepo = null, spec = null }) {
|
|
4517
4733
|
const actions = [];
|
|
4518
4734
|
const push = (id, kind, command, description, extras = {}) => actions.push({ id, kind, command, description, stage: result.stage, ...extras });
|
|
4519
4735
|
const pushPolishCheckpointActions = () => {
|
|
@@ -4541,6 +4757,11 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4541
4757
|
push(inspect.id, inspect.kind, asInvocation(inspect.command), inspect.description, { required: inspect.required });
|
|
4542
4758
|
return actions;
|
|
4543
4759
|
}
|
|
4760
|
+
for (const subcommand of inputRefreshCommands(result.input_currency)) {
|
|
4761
|
+
push("refresh_inputs", "command", `${cmd("record")} ${subcommand} --packet ${shellToken(packetPath)} --json`, subcommand === "brief"
|
|
4762
|
+
? "A recorded stage was made against earlier Campaign Build Brief content: save the brief with record brief (keeps stage history), then re-run next."
|
|
4763
|
+
: "A recorded stage was made against earlier CampaignSpec content: bind it with record spec (keeps stage history), then re-run next.");
|
|
4764
|
+
}
|
|
4544
4765
|
if (result.stage === "doctor-blocked") {
|
|
4545
4766
|
const checkpointGates = (result.gates || []).filter(
|
|
4546
4767
|
(gate) => gate?.status === "blocked" && Object.hasOwn(CHECKPOINT_EVALUATORS, gate?.id || ""),
|
|
@@ -4637,9 +4858,10 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4637
4858
|
if (result.stage === "setup") {
|
|
4638
4859
|
push("setup_skill", "skill", "next-campaigns-os-setup", `Prepare the target page-kit structure and agent context, then record setup with ${cmd("record")} setup --packet ${packetPath}.`);
|
|
4639
4860
|
} else if (result.stage === "build") {
|
|
4640
|
-
|
|
4861
|
+
const recordBuild = `${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
|
|
4862
|
+
push("build_skill", "skill", "next-campaigns-build", `Assemble the campaign per the build prompt, run the page-kit build, then record build with ${recordBuild}.`);
|
|
4641
4863
|
if (isLocalServePacket(packet)) {
|
|
4642
|
-
push("build_local_proof", "command", LOCAL_PROOF_BUILD_COMMAND, `Local proof mode (deploy.target is local-serve): build page-kit in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment into _site
|
|
4864
|
+
push("build_local_proof", "command", LOCAL_PROOF_BUILD_COMMAND, `Local proof mode (deploy.target is local-serve): build page-kit in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment into _site/, then record it with ${recordBuild}, which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}" (never hand-edit it). Vendor loaders are environment-gated out of this render (their protocol-relative //host/... URLs fail over a plain-HTTP local serve); SDK dl_* events still fire. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
|
|
4643
4865
|
push("build_production_parity", "command", asInvocation(substitutePacket(LOCAL_PROOF_PARITY_COMMAND, packetPath)), "After the development build is proven, assert the production render differs from it only in environment-gated output (same pages, route slugs, Campaign Cart pin and next-api-key) before committing; the PR preview is the second check.");
|
|
4644
4866
|
}
|
|
4645
4867
|
if (themeGate?.status === "blocked") {
|
|
@@ -4661,7 +4883,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4661
4883
|
} else if (result.stage === "qa") {
|
|
4662
4884
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4663
4885
|
push("install_browser", "command", `${cmd("qa")} install-browser`, "Install the Playwright browser once after install/update (npm run qa:install-browser from a checkout).");
|
|
4664
|
-
push("qa_run", "command",
|
|
4886
|
+
push("qa_run", "command", qaRunCommand(packetPath, url), "Run browser + typed-card QA and publish the verdict.");
|
|
4887
|
+
const bumpCart = checkoutOrderBumpCart(spec);
|
|
4888
|
+
if (bumpCart) push("qa_run_bump", "command", qaRunCommand(packetPath, url, bumpCart), `Prove the charged add-on. ${orderBumpRunReason(bumpCart)}`);
|
|
4665
4889
|
} else if (result.stage === "done") {
|
|
4666
4890
|
// #171: run-record closeout is a REQUIRED terminal action, not an
|
|
4667
4891
|
// optional nicety — the dogfood run ended at a terminal stage with the
|
|
@@ -4789,9 +5013,9 @@ function templateStockPromptLine(packet, derived = {}) {
|
|
|
4789
5013
|
return `\n- Template-stock pages (declared out of source scope; no design source exists for them): ${pages.join(", ")}. Keep these routes unbuilt by default, especially presell/landing pages staying on another host; never publish stock placeholder copy to fill a partial build. Materialise a page only with explicit operator opt-in for that page (including a pre-checkout select stand-in required by the runtime). For opted-in pages, use the ${packet.assembly.template_family} family's own page for that role with its dependent _includes, _layouts, and assets, wire it from CampaignSpec, and build a required select step first. Once its built HTML exists, doctor lists it among the previewable routes.`;
|
|
4790
5014
|
}
|
|
4791
5015
|
|
|
4792
|
-
function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}) {
|
|
5016
|
+
function buildPrompt(packetPath, contextPath, reportPath, packet, derived = {}, intent) {
|
|
4793
5017
|
const briefPath = packet.build_brief?.normalized_path || "(missing; generate or confirm Campaign Build Brief before business-sensitive assembly)";
|
|
4794
|
-
return
|
|
5018
|
+
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-build for this Campaigns OS handoff.
|
|
4795
5019
|
|
|
4796
5020
|
Read first:
|
|
4797
5021
|
- Build Packet: ${packetPath}
|
|
@@ -4803,7 +5027,8 @@ Read first:
|
|
|
4803
5027
|
|
|
4804
5028
|
Rules:
|
|
4805
5029
|
- Treat CampaignSpec/API as the source for package, shipping, voucher, payment, tracking, footer, and SEO values.
|
|
4806
|
-
- Treat the Campaign Build Brief as the merchandising/design presentation truth: page authority, palette/CTA style, variant media rules, pricing display strategy, promo
|
|
5030
|
+
- Treat the Campaign Build Brief as the merchandising/design presentation truth: page authority, palette/CTA style, variant media rules, pricing display strategy, the template's own promo placeholders, payment/trust surfaces, display-name policy, residue policy, and QA expectations. Agents may resolve implementation uncertainty; unresolved brief questions are business uncertainty and should be asked or recorded, not guessed.
|
|
5031
|
+
- Refresh a changed Campaign Build Brief with \`${cmd("record")} brief --packet ${packetPath}\` and a changed CampaignSpec with \`${cmd("record")} spec --packet ${packetPath}\` before building on the change.
|
|
4807
5032
|
- Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
|
|
4808
5033
|
- Prepared AI/exported HTML must be converted into page-kit-ready source first: keep page-owned body markup, strip document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only for page-kit links/assets/includes. Page Kit publishes src/<slug>/assets/config.js as /<slug>/config.js and src/<slug>/assets/products/foo.png as /<slug>/products/foo.png; do not leave raw /assets/... or /<slug>/assets/... references in rendered pages.
|
|
4809
5034
|
- Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
|
|
@@ -4817,22 +5042,57 @@ Rules:
|
|
|
4817
5042
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
4818
5043
|
- For two-step package-selection flows, treat the selector page as the pre-checkout step and pass the selected cart to checkout with forcePackageId; preserve normal tracking params and strip forcePackageId from visible checkout URLs after SDK initialization.
|
|
4819
5044
|
- After page-kit build, inspect rendered _site output before handoff: each active page should have a body, Campaign Cart runtime markers, SDK meta tags from CampaignSpec sdk_hints.meta_tags, and no stale copied funnel attribution.
|
|
4820
|
-
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
4821
|
-
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet)}`;
|
|
5045
|
+
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
5046
|
+
- Capture the machine-readable build summary as an artifact: \`${PAGE_KIT_BUILD_SUMMARY_CAPTURE_COMMAND}\` (requires next-campaign-page-kit >= 0.1.4). Doctor verifies it for per-page build errors and Page Kit shape warnings (NESTED_NO_PERMALINK, DUPLICATE_OUTPUT, MISSING_FRONTMATTER, LAYOUT_NOT_FOUND). If the installed page-kit predates --json, record that in the assembly report instead of skipping silently.${localProofPromptLines(packet, packetPath)}`;
|
|
4822
5047
|
}
|
|
4823
5048
|
|
|
4824
5049
|
// Local proof mode lines for the build prompt: under local-serve the build is
|
|
4825
5050
|
// the development render, recorded as such, and the production render is
|
|
4826
5051
|
// proven by parity before commit.
|
|
4827
|
-
function localProofPromptLines(packet) {
|
|
5052
|
+
function localProofPromptLines(packet, packetPath = "<packet>") {
|
|
4828
5053
|
if (!isLocalServePacket(packet)) return "";
|
|
4829
5054
|
return `
|
|
4830
|
-
- Local proof mode (deploy.target is local-serve): run the page-kit build in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment — \`${LOCAL_PROOF_BUILD_COMMAND}\` — so _site/ is the development render, and record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD}
|
|
5055
|
+
- Local proof mode (deploy.target is local-serve): run the page-kit build in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment — \`${LOCAL_PROOF_BUILD_COMMAND}\` — so _site/ is the development render, and record it with \`${cmd("record")} build --packet ${packetPath} --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}\`, which sets ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} to "${LOCAL_PROOF_BUILD_ENVIRONMENT}" on the assembly report (never hand-edit it). The starter templates gate every vendor loader on the environment and several loaders are protocol-relative (//host/...), which fail over a plain-HTTP local serve; the SDK's dl_* events still fire in development. Polish capture, browser QA and typed-card orders run against this served output. Before committing, run \`${asInvocation(LOCAL_PROOF_PARITY_COMMAND)}\` to prove the production render differs only in environment-gated output and pins the same Campaign Cart version; the PR preview is the second check. ${LOCAL_PROOF_NEVER_EDIT_RULE}`;
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
// Read the normalized brief the packet records, resolved against the packet
|
|
5059
|
+
// file as doctor resolves it, and summarize it over the Build Context's active
|
|
5060
|
+
// pages in recorded order. A missing, unreadable or invalid brief reads
|
|
5061
|
+
// unavailable; this never fails `next`. Each read or summarising failure is
|
|
5062
|
+
// named on stderr, one line per failure, never on stdout.
|
|
5063
|
+
function readIntentSummary(packetPath, packet, contextPath) {
|
|
5064
|
+
const reportFailure = (error) => process.stderr.write(`campaigns-os: intent summary unavailable: ${singleLineFragment(error?.message ?? error, "unknown error")}\n`);
|
|
5065
|
+
const normalizedPath = resolveFromFile(packetPath, packet?.build_brief?.normalized_path);
|
|
5066
|
+
let brief = null;
|
|
5067
|
+
try {
|
|
5068
|
+
brief = readJsonIfExists(normalizedPath);
|
|
5069
|
+
} catch (error) {
|
|
5070
|
+
reportFailure(error);
|
|
5071
|
+
brief = null;
|
|
5072
|
+
}
|
|
5073
|
+
let activePageIds = [];
|
|
5074
|
+
try {
|
|
5075
|
+
const activePages = readJsonIfExists(contextPath)?.spec?.active_pages;
|
|
5076
|
+
activePageIds = Array.isArray(activePages) ? activePages.map((page) => page?.id) : [];
|
|
5077
|
+
} catch (error) {
|
|
5078
|
+
reportFailure(error);
|
|
5079
|
+
activePageIds = [];
|
|
5080
|
+
}
|
|
5081
|
+
return { ...summarizeCampaignBrief({ brief, activePageIds, onError: reportFailure }), normalized_path: normalizedPath };
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
// The setup, build, Polish and QA prompts start with the campaign intent
|
|
5085
|
+
// summary, framed as orientation only.
|
|
5086
|
+
function campaignIntentPromptHeader(intent) {
|
|
5087
|
+
return `Campaign intent (from the Campaign Build Brief at ${intent.normalized_path || "(not recorded)"}; orientation only, never a source of prices or commerce behaviour):
|
|
5088
|
+
${intent.text}
|
|
5089
|
+
|
|
5090
|
+
`;
|
|
4831
5091
|
}
|
|
4832
5092
|
|
|
4833
|
-
function setupPrompt(packetPath, contextPath, reportPath, packet) {
|
|
5093
|
+
function setupPrompt(packetPath, contextPath, reportPath, packet, intent) {
|
|
4834
5094
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
4835
|
-
return
|
|
5095
|
+
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-os-setup for this Campaigns OS handoff.
|
|
4836
5096
|
|
|
4837
5097
|
Read first:
|
|
4838
5098
|
- Build Packet: ${packetPath}
|
|
@@ -4851,9 +5111,9 @@ When copying a starter template family, copy the family as an atomic page-kit sl
|
|
|
4851
5111
|
Do not wire checkout, upsell, receipt, payment, package, voucher, or shipping behavior during setup.`;
|
|
4852
5112
|
}
|
|
4853
5113
|
|
|
4854
|
-
function polishPrompt(packetPath, reportPath, packet) {
|
|
5114
|
+
function polishPrompt(packetPath, reportPath, packet, intent) {
|
|
4855
5115
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
4856
|
-
return
|
|
5116
|
+
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-polish for this built campaign.
|
|
4857
5117
|
|
|
4858
5118
|
Read first:
|
|
4859
5119
|
- Build Packet: ${packetPath}
|
|
@@ -4952,10 +5212,58 @@ After deploy succeeds:
|
|
|
4952
5212
|
If the deploy is blocked (non-localhost allowed-domain not yet added, CI permission missing, host-side outage), set stages.deploy.status to "blocked" with a clear reason in outputs so the orchestration loop surfaces it rather than skipping past.`;
|
|
4953
5213
|
}
|
|
4954
5214
|
|
|
4955
|
-
|
|
5215
|
+
// The QA command the QA stage hands over. Given a bump cart it is the
|
|
5216
|
+
// order-bump run: the same command with --cart selecting the base tier and
|
|
5217
|
+
// toggling each declared bump.
|
|
5218
|
+
function qaRunCommand(packetPath, url, bumpCart = null) {
|
|
5219
|
+
return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
|
|
5220
|
+
}
|
|
5221
|
+
|
|
5222
|
+
// `qa run --test-order common` never puts a checkout order bump in a test
|
|
5223
|
+
// order: the tier planner skips bump rows by design and bump coverage comes
|
|
5224
|
+
// from --cart. So when the spec declares one, `next` names a second run with
|
|
5225
|
+
// the bump in the cart. It reads the one checkout QA drives, with QA's own row
|
|
5226
|
+
// classifiers: QA's test orders run on findPage(topologies, "checkout"), the
|
|
5227
|
+
// first checkout across funnels in array order, each funnel's pages filtered
|
|
5228
|
+
// to enabled and sorted by `order || 0` (extractTopologies). This picks the
|
|
5229
|
+
// same page, and when that checkout declares no bump it returns null rather
|
|
5230
|
+
// than looking at a later funnel: QA never drives a later funnel's checkout,
|
|
5231
|
+
// so that funnel's bump ref in --cart would land on a checkout without it. The base is the first selector tier QA would plan: QA's default
|
|
5232
|
+
// keeps the page's pre-selected card, which a spec does not name. A checkout
|
|
5233
|
+
// that declares no tier (the cart is filled on an entry page) gets the bump
|
|
5234
|
+
// alone, on whatever selection the entry page made.
|
|
5235
|
+
export function checkoutOrderBumpCart(spec) {
|
|
5236
|
+
const funnels = Array.isArray(spec?.funnels)
|
|
5237
|
+
? spec.funnels
|
|
5238
|
+
: Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
|
|
5239
|
+
for (const funnel of funnels) {
|
|
5240
|
+
const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
|
|
5241
|
+
.filter((page) => page && page.enabled !== false)
|
|
5242
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
5243
|
+
.find((page) => page.type === "checkout");
|
|
5244
|
+
if (!checkout) continue;
|
|
5245
|
+
const bumps = declaredOrderBumps(checkout);
|
|
5246
|
+
if (!bumps.length) return null;
|
|
5247
|
+
const base = declaredSelectorTiers(checkout)[0]?.ref || null;
|
|
5248
|
+
return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
|
|
5249
|
+
}
|
|
5250
|
+
return null;
|
|
5251
|
+
}
|
|
5252
|
+
|
|
5253
|
+
// Why the order-bump run exists, as one sentence for the action and the prompt.
|
|
5254
|
+
function orderBumpRunReason(bumpCart) {
|
|
5255
|
+
const cart = bumpCart.base ? `base tier ${bumpCart.base} plus the bump` : "the bump";
|
|
5256
|
+
return `The checkout declares order bump ${bumpCart.bumps.join(", ")} (is_order_bump or is_upsell), and the default run never puts it in a test order. This run repeats QA with ${cart} in the cart.`;
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
function qaPrompt(packetPath, reportPath, packet, spec = null, intent) {
|
|
4956
5260
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4957
5261
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
4958
|
-
|
|
5262
|
+
const bumpCart = checkoutOrderBumpCart(spec);
|
|
5263
|
+
const bumpCommand = bumpCart
|
|
5264
|
+
? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
|
|
5265
|
+
: "";
|
|
5266
|
+
return `${campaignIntentPromptHeader(intent)}Use next-campaigns-qa for this deployed campaign.
|
|
4959
5267
|
|
|
4960
5268
|
${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
|
|
4961
5269
|
Base URL: ${url}
|
|
@@ -4966,9 +5274,9 @@ Browser install command:
|
|
|
4966
5274
|
${cmd("qa")} install-browser
|
|
4967
5275
|
|
|
4968
5276
|
Node QA command:
|
|
4969
|
-
${
|
|
5277
|
+
${qaRunCommand(packetPath, url)}${bumpCommand}
|
|
4970
5278
|
|
|
4971
|
-
Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods, variant media, promo
|
|
5279
|
+
Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods and template trust badges, variant media, the template's own promo placeholders (demo timers, promo banners, placeholder voucher codes, exit-pop offers), and pricing presentation against the Campaign Build Brief. Do not flag the source design's own proof, urgency or guarantee elements: they are the merchant's content. Test Orders use global test cards that bypass the payment gateway and create no transactions, so they are safe to run any time and need no permission flags, packet policy, or merchant setup. Localhost on any port is a globally allowed Development domain for SDK initialization and suppresses Campaigns analytics events; non-localhost preview/production origins still need the SDK origin allowlist. Use --test-order common for the default depth: every actual terminal path when they fit under the flood cap, otherwise checkout, first-offer accept/decline and a deduplicated shortest real receipt path plus the shortest path that clicks the decline on each offer or downsell page not yet declined, up to the cap (the verdict row browser-test-order:upsell-action-coverage warns naming each offer page whose decline no order clicked); use an explicit path such as accept-decline-accept for a targeted matrix; or use --test-order full for every actual terminal path in the selected checkout topology. Cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. The default accidental-flood cap is 6, and an overflow names the exact explicit --max-test-orders raise. That cap bounds planned paths; --max-order-creations bounds actual order creations and is reserved before each submit click, defaulting to the planned path count. A path whose failure is classified as created (the order is already placed) is inspected read-only and never resubmitted. A not_created failure may be re-run once, if the creation budget has a slot no still-unrun planned path needs; an ambiguous failure stops that path with an explicit operator check instead of buying again. Read evidence.recovery to tell a recovered pass from a first-attempt pass. Click rendered SDK upsell accept/decline controls for upsell proof. For multi-tier package selectors, drive a specific card with --select-package <ref[:qty],...> (strict: the path fails if the requested card cannot be found or selected, unlike best-effort --cart), or use --test-order tiers / tiers:common / tiers:full to drive every selector tier the CampaignSpec declares on the checkout page in one run (order-bump rows marked is_order_bump or is_upsell are add-ons, not tiers; --select-package narrows a tiers run to the listed tiers); prove coupon-bearing orders with --apply-coupon <code> (typed into the rendered promo input, verified against the persisted-order voucher read-back). Reuse one test customer email via --test-email or CAMPAIGNS_OS_QA_TEST_EMAIL (a real monitored inbox in internal runs) so repeated QA does not litter the customer list.
|
|
4972
5280
|
|
|
4973
5281
|
Launch readiness note: Campaigns OS can prove the campaign build, SDK wiring, browser behavior, and typed-card order paths. It does not prove the merchant is ready for real shoppers. Before launch, confirm the production storefront URL, live payment methods, shipping markets, legal/support URLs, analytics expectations, and any merchant-side configuration. Treat those as real-shopper readiness items, not Campaigns OS build blockers.
|
|
4974
5282
|
|
|
@@ -5800,7 +6108,8 @@ export async function toolingStatusCommand(args, options = {}) {
|
|
|
5800
6108
|
const auth = result.gateway_login;
|
|
5801
6109
|
if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
|
|
5802
6110
|
? "Gateway credential storage is unavailable or busy. Check user credential directory permissions and keychain access; wait for another campaigns-os process to finish. See docs/gateway-login.md for interrupted-process recovery."
|
|
5803
|
-
: `Gateway login: ${auth.state}.
|
|
6111
|
+
: `Gateway login: ${auth.state}. Optional: ${result.cli.invocation_prefix} login --store <subdomain> only lets spec derive --from-store fill the Store Profile fields (campaign.store_*).`);
|
|
6112
|
+
if (!auth.accounts.length && auth.state !== "unavailable") result.warnings.push(`Package, offer and shipping refs never come from the gateway login: read them with the campaign's public Campaigns API key (docs/build-packet.md, "Reading package, offer and shipping refs").`);
|
|
5804
6113
|
for (const account of auth.accounts) (account.state === "logged_in" ? result.ready : result.warnings).push(`Gateway login: ${account.state}; store ${account.store}; access remaining ${account.remaining_seconds}s; gateway ${auth.gateway}; reported version ${account.gateway_version || "unavailable"} (local credential metadata only).`);
|
|
5805
6114
|
return result;
|
|
5806
6115
|
}
|
|
@@ -6128,7 +6437,7 @@ function safePolishByteCount(value) {
|
|
|
6128
6437
|
}
|
|
6129
6438
|
|
|
6130
6439
|
export function formatPolishCaptureText(result) {
|
|
6131
|
-
const status = ["ready", "ready_with_waivers", "blocked"].includes(result?.status)
|
|
6440
|
+
const status = ["ready", "ready_with_waivers", "captured_with_warnings", "blocked"].includes(result?.status)
|
|
6132
6441
|
? result.status
|
|
6133
6442
|
: "unknown";
|
|
6134
6443
|
const measurementStatus = ["complete", "incomplete"].includes(result?.measurement?.status)
|
|
@@ -6142,6 +6451,11 @@ export function formatPolishCaptureText(result) {
|
|
|
6142
6451
|
`Status: ${status.toUpperCase()}`,
|
|
6143
6452
|
`Measurement: ${measurementStatus.toUpperCase()}`,
|
|
6144
6453
|
];
|
|
6454
|
+
// Outcomes that did not stop the capture, printed with their own message
|
|
6455
|
+
// (the held page-load plan message on a campaign with no mapped pages).
|
|
6456
|
+
for (const warning of Array.isArray(result?.warnings) ? result.warnings : []) {
|
|
6457
|
+
if (warning?.code === "no_capturable_routes" && typeof warning.message === "string") lines.push(`Warning: ${warning.message}`);
|
|
6458
|
+
}
|
|
6145
6459
|
const incomplete = Array.isArray(result?.measurement?.incomplete)
|
|
6146
6460
|
? result.measurement.incomplete
|
|
6147
6461
|
: [];
|
|
@@ -8038,6 +8352,8 @@ export function nextTinyPromptLines(result) {
|
|
|
8038
8352
|
if (result.stage !== "qa") return lines;
|
|
8039
8353
|
lines.push("");
|
|
8040
8354
|
lines.push(`Next expected proof: browser QA + typed-card proof. Run: ${cmd("qa")} run --packet <packet> --base-url <url> --browser --test-order common`);
|
|
8355
|
+
const bumpRun = (result.next_actions || []).find((action) => action?.id === "qa_run_bump");
|
|
8356
|
+
if (bumpRun) lines.push(`That run never orders the checkout order bump. Prove the charged add-on with: ${bumpRun.command}`);
|
|
8041
8357
|
lines.push("Localhost on any port is a Development domain (SDK allowed, analytics suppressed). Non-localhost origins still need SDK allowlist confirmation.");
|
|
8042
8358
|
lines.push(`Build/polish done but no QA verdict yet is a Completeness Signal, not a build failure: ${cmd("findings")} add --stage qa --kind missing_prompt --summary "..."`);
|
|
8043
8359
|
return lines;
|
|
@@ -8141,6 +8457,8 @@ const WAIVE_ACTIONS = new Set(["theme-waive", "checkpoint-waive"]);
|
|
|
8141
8457
|
// check's readiness line); they are text-only and never enter the JSON result.
|
|
8142
8458
|
export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
8143
8459
|
const lines = [`Status: ${String(result.status || "unknown").toUpperCase()}`, ...headerLines];
|
|
8460
|
+
// `next` only: the campaign intent summary, directly under the status.
|
|
8461
|
+
if (result.intent_summary) lines.push("Campaign intent:", ...result.intent_summary.text.split("\n"));
|
|
8144
8462
|
// A waive command's second line names what it recorded; the third is the
|
|
8145
8463
|
// stage doctor now picks for the report the waiver was written to.
|
|
8146
8464
|
if (WAIVE_ACTIONS.has(result.action) && result.gate) {
|
|
@@ -8148,6 +8466,10 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
8148
8466
|
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
8149
8467
|
if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
|
|
8150
8468
|
}
|
|
8469
|
+
if (result.action === "checkpoint-accept") {
|
|
8470
|
+
for (const accept of result.accepts || []) lines.push(`${result.dry_run ? "Would accept" : "Accepted"}: ${accept.result_ref} by ${accept.accepted_by}${accept.expires_at ? ` until ${accept.expires_at}` : ""}`);
|
|
8471
|
+
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
8472
|
+
}
|
|
8151
8473
|
if (result.action === "record") {
|
|
8152
8474
|
lines.push(`${result.dry_run ? "Would record" : "Recorded"}: ${result.stage}`);
|
|
8153
8475
|
for (const path of result.dry_run ? result.would_write : result.written) lines.push(`${result.dry_run ? "Would write" : "Wrote"}: ${path}${result.dry_run ? " (nothing was written)" : ""}`);
|
|
@@ -8189,6 +8511,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
8189
8511
|
// Directly under the findings they remediate, above the stage picker's
|
|
8190
8512
|
// `Next:` block: the operator reads what is wrong, then what clears it.
|
|
8191
8513
|
lines.push(...doctorRequiredActionLines(result));
|
|
8514
|
+
// `next` only: the QC handoff, after the issues and before the stage picker.
|
|
8515
|
+
if (result.qc_handoff) lines.push(...qcHandoffTextLines(result.qc_handoff));
|
|
8192
8516
|
if (result.next) {
|
|
8193
8517
|
lines.push("Next:");
|
|
8194
8518
|
lines.push(`- ${result.next.stage || "unknown"} (${result.next.owner || result.next.default_skill || "owner unknown"})`);
|