@nextcommerce/campaigns-os 1.54.0 → 1.55.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 +84 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -12
- package/compatibility.json +1 -1
- package/contracts/release-ledger.json +441 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +35 -19
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/effects.md +2 -5
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +55 -11
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
- 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 +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +26 -12
- package/src/brand-theme.mjs +44 -8
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +36 -59
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/doctor/checks.mjs +16 -6
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +4 -3
- package/src/lifecycle.mjs +88 -6
- package/src/local-preview-policy.mjs +6 -0
- package/src/login.mjs +2 -1
- package/src/polish-browser.mjs +8 -2
- package/src/polish-node.mjs +6 -1
- package/src/polish-readability.mjs +2 -2
- package/src/qa-browser.mjs +521 -92
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +144 -39
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-markup.mjs +41 -1
- package/src/stage-ledger.mjs +14 -2
- package/src/stage-record.mjs +50 -13
package/src/cli.mjs
CHANGED
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
refused,
|
|
97
97
|
refusing,
|
|
98
98
|
runWithRefusalScope,
|
|
99
|
+
timeOperatorWait,
|
|
99
100
|
} from "./lifecycle.mjs";
|
|
100
101
|
import { commandNames, optsOutOfRunSession, runInvocation } from "./invocation.mjs";
|
|
101
102
|
import {
|
|
@@ -335,7 +336,7 @@ Usage:
|
|
|
335
336
|
campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
336
337
|
campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
337
338
|
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
|
|
338
|
-
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (
|
|
339
|
+
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--adapter-decision <key>=<value>[,<key>=<value>...]] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: records scalar adapter decisions on the Assembly Report (all pairs in one flag; a repeated flag keeps only the last), stamps stages.assembly.build_fingerprint from doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when present), and makes Polish required unless its evidence is bound to this output; --build-environment records stages.assembly.evidence.build_environment (local proof mode records development)
|
|
339
340
|
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
|
|
340
341
|
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
|
|
341
342
|
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 and the build fingerprint it probed (a later record build of other output makes deploy required again); 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
|
|
@@ -356,7 +357,7 @@ Usage:
|
|
|
356
357
|
campaigns-os next deploy --packet <json> --report <json> [--json]
|
|
357
358
|
campaigns-os next qa --packet <json> --report <json> [--json]
|
|
358
359
|
campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
|
|
359
|
-
campaigns-os qa run --packet <json> [--base-url <url>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
|
|
360
|
+
campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
|
|
360
361
|
campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
361
362
|
campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
|
|
362
363
|
campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
|
|
@@ -364,7 +365,7 @@ Usage:
|
|
|
364
365
|
campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
|
|
365
366
|
campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
|
|
366
367
|
campaigns-os findings export [--summary | --json] [--packet <json>] [--journal <path>]
|
|
367
|
-
campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--
|
|
368
|
+
campaigns-os run-record --packet <json> [--context <json>] [--report <json>] [--qa-verdict <path>] [--run-id <id>] [--new-run] [--journal <path>] [--lifecycle-journal <path>] [--surfaces <a,b>] [--primary-surface <s>] [--surface-confidence <text>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
|
|
368
369
|
run_id: --run-id > the active run session > the most recent Run Record for this packet's campaign (re-emitted in place; a remitted one is left as written) > freshly minted. --new-run always mints; --list prints the run ids on disk for this packet (id, created_at, remit state, path) and, like --no-write, writes and sends nothing.
|
|
369
370
|
--dry-run assembles the record and prints it (with --json: dry_run, would_write, would_remit), then writes no file and sends nothing — where --no-write skips the assembly's reads too. Combining them is allowed and still writes nothing. \`run end --dry-run\` hands the flag on to run-record and leaves the run session open, so the close can still be made for real afterwards.
|
|
370
371
|
|
|
@@ -378,7 +379,7 @@ Usage:
|
|
|
378
379
|
campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
|
|
379
380
|
campaigns-os run end [--packet <json>] [--no-remit] [--no-write] [--proxy-base <url>] [--json] # assemble the aggregated Run Record for the session, then clear it (also closes out a stale session at cwd); --proxy-base is handed to run-record, so the session record remits to that receiver under the consent scoped to it
|
|
380
381
|
|
|
381
|
-
Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (\`theme waive
|
|
382
|
+
Gates: when theme inspect finds a generatable brand theme and the campaign ships commerce pages, \`next polish|deploy|qa\` and \`qa run\` BLOCK until the brand layer is applied after next-core.css or explicitly waived (recorded: \`theme waive --packet <p> --reason "<why>" --waived-by "<named human>"\`; one-run ephemeral: \`qa run --theme-waive "<reason>"\`).
|
|
382
383
|
Commercial parity: \`qa run\` automatically compares contract-governed authored price/cadence/voucher claims with fresh \`/api/price-preview\` evidence; no extra catalog flag is required.
|
|
383
384
|
Local proof mode: under deploy.target local-serve the build stage renders the DEVELOPMENT environment (\`CPK_ENV=development npx campaign-build --json > .campaign-runtime/page-kit-build-summary.json\`, recorded as stages.assembly.evidence.build_environment) into _site/, and polish capture, browser QA and typed-card orders run against that served output; starter templates gate every vendor loader on the environment, and a production build's protocol-relative loaders (//host/...) fail over a plain-HTTP local serve. \`page-kit parity\` then proves the pin on the production render before commit; the PR preview is the second check. The toolkit never proposes editing a generated include to make a local capture pass.
|
|
384
385
|
Wrapper policy: \`start\`/\`prepare-build\`/\`build\` seed source_html.adapter_contract.wrapper_policy from --wrapper-policy, else the source-html manifest's wrapper_policy key, else strip_document_wrappers. Selecting preserve_document_wrappers reports source_html.prep.document_wrapper as a warning instead of blocking, so raw-HTML source can be handed over without a wrapper-stripping pass (docs/source-adapters.md).
|
|
@@ -731,7 +732,7 @@ export function recordQaStageOutcome(args, result) {
|
|
|
731
732
|
outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
|
|
732
733
|
blockers: verdict.disposition === "blocked" ? failed : [],
|
|
733
734
|
warnings: verdict.disposition === "ready_with_exceptions"
|
|
734
|
-
? ["QA
|
|
735
|
+
? ["QA passed with explicitly attributed exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass."]
|
|
735
736
|
: [],
|
|
736
737
|
// The producer knows its own run id and must restate it, or the stage
|
|
737
738
|
// keeps a previous run's identity beside this run's status and outputs.
|
|
@@ -897,6 +898,7 @@ async function autoEndRunSessionAfterTerminalQa(args, sessionHolder, thrown, imp
|
|
|
897
898
|
|
|
898
899
|
const summary = await closeRunSession(updatedFound, {
|
|
899
900
|
packet,
|
|
901
|
+
closedBy: "qa_auto_end",
|
|
900
902
|
extraArgs,
|
|
901
903
|
silent: true,
|
|
902
904
|
promptForConsent: false,
|
|
@@ -1020,7 +1022,13 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1020
1022
|
args.spec = resolved.specPath;
|
|
1021
1023
|
// `command` rides along for the doctor sidecar's generated_by stamp when
|
|
1022
1024
|
// the mode runs doctor (#312): threaded from here, not re-read from argv.
|
|
1023
|
-
|
|
1025
|
+
// Doctor runs inside prepare-build, so its codes are recorded while that
|
|
1026
|
+
// phase is active and attach to it.
|
|
1027
|
+
const result = await recorder.time("prepare-build", async () => {
|
|
1028
|
+
const prepared = await prepareBuild(args, { ...mode, command, specInput, publishSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag });
|
|
1029
|
+
if (prepared.doctor && !prepared.doctor.ok) recorder.recordFindingCodes(prepared.doctor.errors?.map((issue) => issue.code));
|
|
1030
|
+
return prepared;
|
|
1031
|
+
});
|
|
1024
1032
|
result.spec_source = resolved;
|
|
1025
1033
|
autoStartRunSession(result, args, ambient, sessionHolder);
|
|
1026
1034
|
printPrepareResult(result, args);
|
|
@@ -1032,6 +1040,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1032
1040
|
// synchronous inspection; see readDoctorLiveCampaign.
|
|
1033
1041
|
const liveCampaign = await readDoctorLiveCampaign(args);
|
|
1034
1042
|
const result = doctorCommand(args, { liveCampaign, qcStandIns });
|
|
1043
|
+
if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
|
|
1035
1044
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1036
1045
|
printDoctorTinyPrompt(result, args);
|
|
1037
1046
|
return;
|
|
@@ -1225,6 +1234,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1225
1234
|
// command a QA run prints has to agree with the run_id this session will
|
|
1226
1235
|
// later close and remit under.
|
|
1227
1236
|
const result = await runQaCli(args, { ambient });
|
|
1237
|
+
if (result?.verdict?.disposition === "blocked") {
|
|
1238
|
+
recorder.recordFindingCodes(result.verdict.assertions?.filter((item) => item.status === "fail" || item.severity === "blocker")
|
|
1239
|
+
.map((item) => item.evidence?.code || item.id));
|
|
1240
|
+
}
|
|
1228
1241
|
// nextStage requires a packet. Guard its optional, swallowed progress
|
|
1229
1242
|
// probe explicitly so it cannot construct a refusal in that try block.
|
|
1230
1243
|
if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
|
|
@@ -1968,7 +1981,7 @@ function prepareBuildUnderLock({
|
|
|
1968
1981
|
},
|
|
1969
1982
|
qa: {
|
|
1970
1983
|
proof_policy: proofPolicy,
|
|
1971
|
-
test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions.
|
|
1984
|
+
test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions, but each creates a real store order record. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing test orders; then run `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 controls coverage after the operator has answered.",
|
|
1972
1985
|
},
|
|
1973
1986
|
notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
|
|
1974
1987
|
};
|
|
@@ -3436,7 +3449,7 @@ export function pageKitSyncCommand(args) {
|
|
|
3436
3449
|
result.written = true;
|
|
3437
3450
|
if (stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
3438
3451
|
addIssue(result.warnings, "page_kit.sync.build_stale", `The Assembly Report records a terminal build (stages.assembly.status ${report.stages.assembly.status}), and the built output was rendered from the entry just rewritten: its store profile links, phone and Campaign Cart loader pin are now stale. Re-run the build stage before polish, deploy or QA; doctor's page-kit gates read ${PAGE_KIT_CAMPAIGNS_REL_PATH}, not _site/.`);
|
|
3439
|
-
result.next = `${cmd("doctor")} --packet ${shellToken(packetPath)}, then rebuild
|
|
3452
|
+
result.next = `${cmd("doctor")} --packet ${shellToken(packetPath)}, then rebuild with page-kit, record it with ${cmd("record")} build --packet ${shellToken(packetPath)} and run ${cmd("next")} --packet ${shellToken(packetPath)}`;
|
|
3440
3453
|
}
|
|
3441
3454
|
// The retained doctor snapshot (if any) now predates the entry it judged.
|
|
3442
3455
|
// The data file is already written, so a failure here is a warning on the
|
|
@@ -4439,7 +4452,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4439
4452
|
errors,
|
|
4440
4453
|
warnings,
|
|
4441
4454
|
ready,
|
|
4442
|
-
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status.
|
|
4455
|
+
prompt: `Pipeline complete. All stages in the assembly report are in a terminal status. To repeat build work, do the work and run \`${cmd("record")} build --packet <path>\`; this makes downstream stages owed as needed. Run \`${cmd("qa")} run\` for QA. For a hosted deploy, record the URL and stage outcome as the deploy prompt describes. Then call \`${cmd("next")}\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.${report?.stages?.qa?.status === "completed_with_warnings" ? " QA passed with exceptions; report them to the operator without clearing or waiving them or changing markup just to make them pass." : ""}`,
|
|
4443
4456
|
});
|
|
4444
4457
|
}
|
|
4445
4458
|
stage = picked.stage;
|
|
@@ -4700,7 +4713,7 @@ function themeStarterPaletteAdvisory(themeGate, packetPath, residueState) {
|
|
|
4700
4713
|
+ "runs the template-residue checks at blocker severity, so `qa run` will block on "
|
|
4701
4714
|
+ "template-residue:<page>:style:* rows for the starter call-to-action colour. Decide before QA, "
|
|
4702
4715
|
+ "not after a blocked verdict. Either record an explicit operator waiver — "
|
|
4703
|
-
+ `\`${cmd("theme")} waive --packet ${packetArg} --reason "<why the starter palette is acceptable>"\` `
|
|
4716
|
+
+ `\`${cmd("theme")} waive --packet ${packetArg} --reason "<why the starter palette is acceptable>" --waived-by "<named human>"\` `
|
|
4704
4717
|
+ "— which downgrades those rows to warn severity and keeps the shipped palette visible in the "
|
|
4705
4718
|
+ "verdict; or hand-author the brand layer (write brand-theme.css, list it after next-core.css in "
|
|
4706
4719
|
+ "commerce-page frontmatter styles, rebuild, then run "
|
|
@@ -5061,7 +5074,7 @@ Rules:
|
|
|
5061
5074
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
5062
5075
|
- 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.
|
|
5063
5076
|
- 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.
|
|
5064
|
-
- 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.
|
|
5077
|
+
- 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}` : ""} --adapter-decision <key>=<value>[,<key>=<value>...]\`. Record the value that is true for this build: for example, raw_html_conversion_status=completed once source HTML is converted, or not_required when there is none. Put all scalar adapter decisions in one comma-separated flag; a repeated flag keeps only the last. Use the allowed values in docs/build-packet.md. Record the nine recordable scalar decisions with the command, never by editing those fields in the Assembly Report. wrapper_policy is selected at intake with prepare-build --wrapper-policy or the source-html manifest's wrapper_policy option; the object-valued template_files_copied proof has no flag. 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.
|
|
5065
5078
|
- 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)}`;
|
|
5066
5079
|
}
|
|
5067
5080
|
|
|
@@ -5295,7 +5308,7 @@ ${cmd("qa")} install-browser
|
|
|
5295
5308
|
Node QA command:
|
|
5296
5309
|
${qaRunCommand(packetPath, url)}${bumpCommand}
|
|
5297
5310
|
|
|
5298
|
-
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,
|
|
5311
|
+
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, but each creates a real order record on the store. Unless the operator has already said test orders are fine for this campaign, ask once, up front, before placing test orders; then proceed. No permission flag, packet policy, or merchant setup is required. 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.
|
|
5299
5312
|
|
|
5300
5313
|
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.
|
|
5301
5314
|
|
|
@@ -6621,10 +6634,10 @@ async function promptForFinding(current) {
|
|
|
6621
6634
|
const { createInterface } = await import("node:readline/promises");
|
|
6622
6635
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6623
6636
|
try {
|
|
6624
|
-
const stage = current.stage || (await rl.question(`Stage (${FINDING_STAGES.join("/")}): `)).trim();
|
|
6625
|
-
const kind = current.kind || (await rl.question(`Kind (${FINDING_KINDS.join("/")}): `)).trim();
|
|
6626
|
-
const summary = current.summary || (await rl.question("Summary: ")).trim();
|
|
6627
|
-
const details = current.details || (await rl.question("Details (optional): ")).trim() || null;
|
|
6637
|
+
const stage = current.stage || (await timeOperatorWait(() => rl.question(`Stage (${FINDING_STAGES.join("/")}): `))).trim();
|
|
6638
|
+
const kind = current.kind || (await timeOperatorWait(() => rl.question(`Kind (${FINDING_KINDS.join("/")}): `))).trim();
|
|
6639
|
+
const summary = current.summary || (await timeOperatorWait(() => rl.question("Summary: "))).trim();
|
|
6640
|
+
const details = current.details || (await timeOperatorWait(() => rl.question("Details (optional): "))).trim() || null;
|
|
6628
6641
|
return { stage, kind, summary, details };
|
|
6629
6642
|
} finally {
|
|
6630
6643
|
rl.close();
|
|
@@ -7099,7 +7112,7 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
7099
7112
|
// `run end` is "assemble the Run Record for this session and stop logging to
|
|
7100
7113
|
// it". A throw leaves the session active so the operator can fix the packet
|
|
7101
7114
|
// and re-run `run end`. In text mode run-record prints its own summary.
|
|
7102
|
-
const summary = await closeRunSession(found, { packet, extraArgs: args, silent: args.json === true });
|
|
7115
|
+
const summary = await closeRunSession(found, { packet, extraArgs: args, silent: args.json === true, closedBy: "run_end" });
|
|
7103
7116
|
return { result: summary, exitCode: 0 };
|
|
7104
7117
|
}
|
|
7105
7118
|
|
|
@@ -7114,13 +7127,9 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
7114
7127
|
// `dryRun` entries in src/invocation.mjs and autoEndRunSessionAfterTerminalQa.
|
|
7115
7128
|
export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
|
|
7116
7129
|
"context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
|
|
7117
|
-
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
|
|
7118
7130
|
"no-remit", "no-write", "proxy-base", "dry-run", "json",
|
|
7119
7131
|
]);
|
|
7120
7132
|
const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
|
|
7121
|
-
const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
|
|
7122
|
-
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
|
|
7123
|
-
]);
|
|
7124
7133
|
|
|
7125
7134
|
// The run-record argv that closes `session`: its run_id and journal, the
|
|
7126
7135
|
// packet the closer resolved, and only the inheritable flags of `extraArgs`.
|
|
@@ -7144,7 +7153,7 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
|
|
|
7144
7153
|
// session is cleared only AFTER run-record succeeds: on a throw it stays on
|
|
7145
7154
|
// disk, and the error is rethrown unless `onError` takes it, in which case
|
|
7146
7155
|
// the closer returns null.
|
|
7147
|
-
async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null } = {}) {
|
|
7156
|
+
async function closeRunSession(found, { packet, extraArgs = {}, silent = false, promptForConsent = true, onError = null, closedBy } = {}) {
|
|
7148
7157
|
const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
|
|
7149
7158
|
try {
|
|
7150
7159
|
// No internal closeout currently constructs a refusal before its invoking
|
|
@@ -7152,7 +7161,7 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
|
|
|
7152
7161
|
// run-record, run end validates its own argv first, and QA auto-end runs
|
|
7153
7162
|
// after QA persistence. Keep the scope at this boundary so a future
|
|
7154
7163
|
// closeout refusal swallowed by onError cannot mark the outer invocation.
|
|
7155
|
-
const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
|
|
7164
|
+
const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent, closedBy }));
|
|
7156
7165
|
// Clearing the session is a write like any other, so a closer carrying
|
|
7157
7166
|
// --dry-run leaves it open: the operator sees the record the close would
|
|
7158
7167
|
// assemble and can still close for real afterwards.
|
|
@@ -7233,6 +7242,7 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
|
|
|
7233
7242
|
if (packet && existsSync(packet)) {
|
|
7234
7243
|
const summary = await closeRunSession(stale, {
|
|
7235
7244
|
packet,
|
|
7245
|
+
closedBy: "stale_sweep",
|
|
7236
7246
|
extraArgs: { ...inherited, json: true },
|
|
7237
7247
|
silent: true,
|
|
7238
7248
|
promptForConsent: false,
|
|
@@ -7261,12 +7271,11 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
|
|
|
7261
7271
|
// run-record.mjs to assemble the manifest, then (consent-gated, non-fatal)
|
|
7262
7272
|
// remit it. Capture is ALWAYS local; consent gates only the remit. See
|
|
7263
7273
|
// docs/workflow-findings-sidecar.md.
|
|
7264
|
-
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
|
|
7274
|
+
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true, closedBy = "manual" } = {}) {
|
|
7265
7275
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
7266
7276
|
if (args["new-run"] === true && optionalString(args["run-id"])) {
|
|
7267
7277
|
throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
|
|
7268
7278
|
}
|
|
7269
|
-
const agentUsage = refusing(() => parseAgentUsageArgs(args));
|
|
7270
7279
|
// --dry-run assembles the record and shows it, then writes and sends
|
|
7271
7280
|
// nothing. It differs from --no-write, which skips the assembly's reads
|
|
7272
7281
|
// as well; combining the two is allowed and still writes nothing.
|
|
@@ -7503,6 +7512,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
7503
7512
|
packageVersion: packageVersion(),
|
|
7504
7513
|
...toolkitProvenance({ silent }),
|
|
7505
7514
|
qaVerdictPublish,
|
|
7515
|
+
closedBy,
|
|
7506
7516
|
command: "run-record",
|
|
7507
7517
|
argvShape: argvShape(args),
|
|
7508
7518
|
consent: { state: consent.state, source: consent.source },
|
|
@@ -7524,7 +7534,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
7524
7534
|
primarySurface: optionalString(args["primary-surface"]),
|
|
7525
7535
|
surfaceConfidence: optionalString(args["surface-confidence"]),
|
|
7526
7536
|
lifecycle,
|
|
7527
|
-
agentUsage,
|
|
7528
7537
|
});
|
|
7529
7538
|
|
|
7530
7539
|
const shouldAttemptRemit = !remitDisabled && consent.state === "on";
|
|
@@ -7886,9 +7895,7 @@ function parseRunRecordSurfaces(value) {
|
|
|
7886
7895
|
|
|
7887
7896
|
function validateRunRecordArgv(args) {
|
|
7888
7897
|
for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
|
|
7889
|
-
|
|
7890
|
-
// their non-negative-integer diagnostics for bare and blank values.
|
|
7891
|
-
if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && !RUN_RECORD_INTEGER_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
7898
|
+
if (!RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS.has(flag) && Object.hasOwn(args, flag)) requireArg(args, flag);
|
|
7892
7899
|
}
|
|
7893
7900
|
if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
|
|
7894
7901
|
if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
|
|
@@ -7898,41 +7905,11 @@ function validateRunRecordArgv(args) {
|
|
|
7898
7905
|
throw refused("run-record: --new-run and --run-id are exclusive; --run-id names the run to re-emit, --new-run mints a fresh one.");
|
|
7899
7906
|
}
|
|
7900
7907
|
return {
|
|
7901
|
-
agentUsage: refusing(() => parseAgentUsageArgs(args)),
|
|
7902
7908
|
dryRun: isDryRun(args),
|
|
7903
7909
|
parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
|
|
7904
7910
|
};
|
|
7905
7911
|
}
|
|
7906
7912
|
|
|
7907
|
-
function parseAgentUsageArgs(args) {
|
|
7908
|
-
const fields = {
|
|
7909
|
-
"agent-input-tokens": "input_tokens",
|
|
7910
|
-
"agent-output-tokens": "output_tokens",
|
|
7911
|
-
"agent-tool-output-tokens": "tool_output_tokens",
|
|
7912
|
-
"agent-total-tokens": "total_tokens",
|
|
7913
|
-
"agent-elapsed-ms": "elapsed_ms",
|
|
7914
|
-
};
|
|
7915
|
-
const usage = {};
|
|
7916
|
-
for (const [flag, field] of Object.entries(fields)) {
|
|
7917
|
-
if (!(flag in args)) continue;
|
|
7918
|
-
usage[field] = parseNonNegativeIntegerFlag(args[flag], flag);
|
|
7919
|
-
}
|
|
7920
|
-
if (isNonEmptyString(args["agent-model"])) usage.model = args["agent-model"].trim();
|
|
7921
|
-
if (isNonEmptyString(args["agent-usage-source"])) usage.source = args["agent-usage-source"].trim();
|
|
7922
|
-
return Object.keys(usage).length ? usage : null;
|
|
7923
|
-
}
|
|
7924
|
-
|
|
7925
|
-
function parseNonNegativeIntegerFlag(value, flag) {
|
|
7926
|
-
if (value === true || value === false || value == null || String(value).trim() === "") {
|
|
7927
|
-
throw new Error(`--${flag} requires a non-negative integer value.`);
|
|
7928
|
-
}
|
|
7929
|
-
const parsed = Number(value);
|
|
7930
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
7931
|
-
throw new Error(`--${flag} must be a non-negative integer.`);
|
|
7932
|
-
}
|
|
7933
|
-
return parsed;
|
|
7934
|
-
}
|
|
7935
|
-
|
|
7936
7913
|
function packageVersion() {
|
|
7937
7914
|
return readJson(join(ROOT, "package.json")).version;
|
|
7938
7915
|
}
|
package/src/consent.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
+
import { timeOperatorWait } from "./lifecycle.mjs";
|
|
17
18
|
|
|
18
19
|
export const TELEMETRY_ENV_VAR = "CAMPAIGNS_OS_TELEMETRY";
|
|
19
20
|
export const TELEMETRY_CONFIG_SCHEMA = "campaigns-os-telemetry-config/v0";
|
|
@@ -337,9 +338,9 @@ export async function promptAndPersistConsent({
|
|
|
337
338
|
if (existing.resolved) return { ...existing, prompted: false };
|
|
338
339
|
if (!isTTY) return { state: "off", source: "default", resolved: false, prompted: false };
|
|
339
340
|
|
|
340
|
-
const answer = await ask(
|
|
341
|
+
const answer = await timeOperatorWait(() => ask(
|
|
341
342
|
"Campaigns OS can send build telemetry and minimal stage observations to Next Commerce to improve templates, tools, and guidance; stage observations support Workspace progress. Share telemetry from this machine? [Y/n] (change any time): ",
|
|
342
|
-
);
|
|
343
|
+
));
|
|
343
344
|
const normalized = String(answer || "").trim().toLowerCase();
|
|
344
345
|
const state = (normalized === "" || ["y", "yes", "1", "true", "on"].includes(normalized)) ? "on" : "off";
|
|
345
346
|
writeConsentConfig(state, { configPath, proxyBase, source: "prompt", now });
|
|
@@ -69,7 +69,7 @@ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {
|
|
|
69
69
|
throw new Error(
|
|
70
70
|
`Assembly report at ${reportPath} ${announced ? "gained stage evidence while prepare-build was running" : "already carries stage evidence"} (${stageKeys.join(", ")}). `
|
|
71
71
|
+ `Rerunning prepare-build/start/build would reset ${stageKeys.length === 1 ? "this stage" : "these stages"} to pending and destroy that evidence. `
|
|
72
|
-
+ `Pass --force to overwrite (destructive).`,
|
|
72
|
+
+ `If the brief or CampaignSpec changed, run record brief or record spec instead to bind it while keeping that evidence. Pass --force only to overwrite (destructive).`,
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
|
package/src/deviation.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const DEVIATION_JOURNAL_REL_PATH = ".campaign-runtime/agent-deviations.js
|
|
|
15
15
|
|
|
16
16
|
// Commands that advance the pipeline. Read-only / bookkeeping commands
|
|
17
17
|
// (doctor, next, findings, telemetry, run, validate-*) never deviate.
|
|
18
|
-
export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["
|
|
18
|
+
export const TRACKED_STAGE_COMMANDS = Object.freeze(new Set(["prepare-build", "theme", "polish", "qa", "run-record"]));
|
|
19
19
|
|
|
20
20
|
// Setup and metadata subcommands of a tracked command. They produce no stage
|
|
21
21
|
// output, so running one outside the recommendation is not a detour:
|
|
@@ -77,6 +77,7 @@ export function buildRecommendation({ stage, status, expectedCommands, now = new
|
|
|
77
77
|
* recommendation. Returns a deviation entry or null.
|
|
78
78
|
*/
|
|
79
79
|
export function detectDeviation({ lastRecommendation, command, subcommand = null, argvShape = [], runId = null, deviationReason = null, now = new Date() }) {
|
|
80
|
+
if (argvShape.includes("--help")) return null;
|
|
80
81
|
if (!TRACKED_STAGE_COMMANDS.has(command)) return null;
|
|
81
82
|
if (subcommand && UNTRACKED_SUBCOMMANDS.has(`${command} ${subcommand}`)) return null;
|
|
82
83
|
if (!lastRecommendation || !Array.isArray(lastRecommendation.expected_commands)) return null;
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -440,7 +440,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
440
440
|
{
|
|
441
441
|
id: SDK_MARKUP,
|
|
442
442
|
phase: "built-output",
|
|
443
|
-
run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
|
|
443
|
+
run: ({ spec, packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived, spec),
|
|
444
444
|
},
|
|
445
445
|
{
|
|
446
446
|
id: SCRIPT_SYNTAX,
|
|
@@ -832,7 +832,7 @@ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, re
|
|
|
832
832
|
const packetContract = packet.source_html?.adapter_contract;
|
|
833
833
|
validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
|
|
834
834
|
validateAdapterSourceFiles({
|
|
835
|
-
decisions: packetContract,
|
|
835
|
+
decisions: buildState.report?.adapter_decisions || buildState.context?.adapter_decisions || packetContract,
|
|
836
836
|
sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
|
|
837
837
|
pages: packet.source_html?.pages || [],
|
|
838
838
|
warnings,
|
|
@@ -2147,6 +2147,10 @@ export function validateCampaignIdentity(packet, errors, ready, derived, spec =
|
|
|
2147
2147
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2148
2148
|
},
|
|
2149
2149
|
pages,
|
|
2150
|
+
expectedApiKey: (() => {
|
|
2151
|
+
const resolved = resolveCampaignsApiKeySource(packet, null, process.env, { spec });
|
|
2152
|
+
return resolved.key ? { key: resolved.key, source: resolved.origin } : null;
|
|
2153
|
+
})(),
|
|
2150
2154
|
errors,
|
|
2151
2155
|
ready,
|
|
2152
2156
|
derived,
|
|
@@ -2290,8 +2294,8 @@ function readBoundedScript(realSiteRoot, path, maxBytes) {
|
|
|
2290
2294
|
}
|
|
2291
2295
|
}
|
|
2292
2296
|
|
|
2293
|
-
function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
|
|
2294
|
-
const gate = evaluateCampaignIdentity({ subject, pages });
|
|
2297
|
+
function recordCampaignIdentityGate({ subject, pages, expectedApiKey = null, errors, ready, derived }) {
|
|
2298
|
+
const gate = evaluateCampaignIdentity({ subject, pages, expectedApiKey });
|
|
2295
2299
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
2296
2300
|
|
|
2297
2301
|
if (gate.status === "blocked") {
|
|
@@ -2315,17 +2319,23 @@ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived })
|
|
|
2315
2319
|
// Static SDK markup checks (#303). Every doctor invocation, both entry points,
|
|
2316
2320
|
// filesystem enumeration, blocking regardless of stage status — the same
|
|
2317
2321
|
// contract as the two gates above, for the same reasons.
|
|
2318
|
-
function validateSdkMarkup(packet, errors, warnings, ready, derived) {
|
|
2322
|
+
function validateSdkMarkup(packet, errors, warnings, ready, derived, spec = null) {
|
|
2319
2323
|
const targetRepo = derived.target_repo;
|
|
2320
2324
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
2321
2325
|
const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
|
|
2322
2326
|
const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
|
|
2327
|
+
const declaredTypes = new Map(activeSpecPages(spec).filter((page) => ["receipt", "thankyou"].includes(page.type)).map((page) => [
|
|
2328
|
+
builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived), "receipt",
|
|
2329
|
+
]));
|
|
2323
2330
|
recordSdkMarkupGate({
|
|
2324
2331
|
subject: {
|
|
2325
2332
|
public_route_slug: publicRouteSlug || null,
|
|
2326
2333
|
site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
|
|
2327
2334
|
},
|
|
2328
|
-
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo)
|
|
2335
|
+
pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo).map((page) => ({
|
|
2336
|
+
...page,
|
|
2337
|
+
page_type: declaredTypes.get(resolve(targetRepo, page.file)) || null,
|
|
2338
|
+
})) : [],
|
|
2329
2339
|
errors,
|
|
2330
2340
|
warnings,
|
|
2331
2341
|
ready,
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The next step doctor recommends, and the gate issues `next` reads from doctor.
|
|
2
2
|
import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { polishCarriedForwardForLadder } from "../local-preview-policy.mjs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { orderPathDepthDriftText } from "../proof-policy.mjs";
|
|
6
|
-
import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
|
|
6
|
+
import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
|
|
7
7
|
import {
|
|
8
8
|
SOURCE_PREP_DOCUMENT_WRAPPER,
|
|
9
9
|
SOURCE_PREP_FRONTMATTER_RESIDUE,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
stageIsBlocked,
|
|
17
17
|
stageIsTerminal,
|
|
18
18
|
} from "../orchestration-stage-contract.mjs";
|
|
19
|
-
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
19
|
+
import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
20
|
import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
|
|
21
21
|
import { cmd } from "../install-invocation.mjs";
|
|
22
22
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
@@ -473,9 +473,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
for (const cliStage of NEXT_STAGE_ORDER) {
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
if (cliStage === "polish" && polishGate
|
|
476
|
+
// A first local preview may carry missing Polish forward. A theme waiver
|
|
477
|
+
// or prior capture makes the recorded Polish stage owed again.
|
|
478
|
+
if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
|
|
479
479
|
const reportKey = reportKeyForCliStage(cliStage);
|
|
480
480
|
const stage = report.stages[reportKey];
|
|
481
481
|
if (!stage) {
|
|
@@ -500,6 +500,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
500
500
|
reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
|
|
501
501
|
};
|
|
502
502
|
}
|
|
503
|
+
if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
|
|
504
|
+
&& !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
|
|
505
|
+
return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
|
|
506
|
+
}
|
|
503
507
|
// A terminal QA status is not the same claim as purchase proof. QA finalizes
|
|
504
508
|
// a verdict and records a terminal status even when no order path ran, so a
|
|
505
509
|
// `--test-order off` diagnostic used to carry the pipeline to "done" against
|
|
@@ -711,6 +715,12 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
711
715
|
const fallbackAction = picked.stage === "done"
|
|
712
716
|
? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
|
|
713
717
|
: `Run ${command}.`;
|
|
718
|
+
if (picked.stage === "done") {
|
|
719
|
+
actions.unshift(fallbackAction);
|
|
720
|
+
if (report?.stages?.qa?.status === "completed_with_warnings") {
|
|
721
|
+
actions.push("QA passed with exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass.");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
714
724
|
return {
|
|
715
725
|
stage: picked.stage,
|
|
716
726
|
status: blocked ? "blocked" : readinessStatus(warnings, derived),
|
package/src/invocation.mjs
CHANGED
|
@@ -132,14 +132,15 @@ export function resolveInvocationPolicy(command, args) {
|
|
|
132
132
|
const steps = CLASS_STEPS[rule.class];
|
|
133
133
|
const noWrite = args["no-write"] === true;
|
|
134
134
|
const implementsDryRun = rule.dryRun === true;
|
|
135
|
-
const
|
|
135
|
+
const help = Boolean(args.help);
|
|
136
|
+
const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
|
|
136
137
|
const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
|
|
137
138
|
return Object.freeze({
|
|
138
139
|
class: rule.class,
|
|
139
140
|
wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
|
|
140
141
|
sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
|
|
141
|
-
journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
|
|
142
|
-
implementsDryRun, autoEnd: rule.autoEnd === true,
|
|
142
|
+
journalExempt: help || !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
|
|
143
|
+
implementsDryRun, autoEnd: !help && rule.autoEnd === true,
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
|