@nextcommerce/campaigns-os 1.50.0 → 1.52.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 +426 -0
- package/agents/claude/CLAUDE.md +2 -2
- package/agents/codex/AGENTS.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/campaign-spec/dist/rules/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 +81 -2
- package/contracts/release-ledger.json +906 -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/build-packet.md +93 -9
- package/docs/campaign-build-brief.md +25 -1
- package/docs/effects.md +6 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +10 -0
- package/docs/qa-and-test-orders.md +45 -4
- 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 +3 -3
- package/skills/next-campaigns-os/SKILL.md +4 -4
- package/skills/next-campaigns-os/references/session-intake.md +7 -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 +6 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +12 -0
- package/src/build-brief.mjs +68 -21
- 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 +310 -34
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/doctor/checks.mjs +291 -24
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +1 -1
- package/src/invocation.mjs +2 -1
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +218 -1
- package/src/polish-capture.mjs +1 -1
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +96 -4
- package/src/progress-node.mjs +5 -1
- package/src/qa-browser.mjs +308 -96
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +104 -12
- 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 +446 -0
- package/src/qc-check-registry.mjs +83 -0
- package/src/qc-results.mjs +1049 -0
- 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-record.mjs +56 -17
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";
|
|
@@ -119,6 +123,7 @@ import { crawlSourceAssetPaths } from "./source-asset-crawl.mjs";
|
|
|
119
123
|
import {
|
|
120
124
|
THEME_POLICIES,
|
|
121
125
|
inspectBrandTheme,
|
|
126
|
+
themeIssueForReport,
|
|
122
127
|
writeThemeArtifacts,
|
|
123
128
|
} from "./brand-theme.mjs";
|
|
124
129
|
import {
|
|
@@ -144,6 +149,7 @@ import {
|
|
|
144
149
|
LOCAL_PROOF_BUILD_ENVIRONMENT_SCOPE,
|
|
145
150
|
LOCAL_PROOF_NEVER_EDIT_RULE,
|
|
146
151
|
LOCAL_PROOF_PARITY_COMMAND,
|
|
152
|
+
LOCAL_PROOF_RECORD_BUILD_COMMAND,
|
|
147
153
|
LOCAL_PROOF_PARITY_FIELD,
|
|
148
154
|
LOCAL_PROOF_PARITY_SCOPE,
|
|
149
155
|
recordedBuildEnvironment,
|
|
@@ -194,7 +200,7 @@ import {
|
|
|
194
200
|
capturePolishPageLoad,
|
|
195
201
|
createPolishCaptureBinding,
|
|
196
202
|
evaluateRecordedHiddenEagerMediaCheckpoint,
|
|
197
|
-
|
|
203
|
+
mergePolishCaptureEvidence,
|
|
198
204
|
planPolishCapture,
|
|
199
205
|
} from "./polish-node.mjs";
|
|
200
206
|
import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
|
|
@@ -317,12 +323,13 @@ Usage:
|
|
|
317
323
|
campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
|
|
318
324
|
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
325
|
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
|
|
326
|
+
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
327
|
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
328
|
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
329
|
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
330
|
campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
|
|
324
331
|
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
|
|
332
|
+
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
333
|
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
334
|
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
335
|
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 +465,12 @@ function closestCommand(input) {
|
|
|
458
465
|
// invocation policy: src/invocation.mjs owns it and main() only hands over the
|
|
459
466
|
// mechanisms. The raw argv rides along for the handlers that must see it
|
|
460
467
|
// (authentication, and the two raw-token validators).
|
|
461
|
-
|
|
468
|
+
// `qcStandIns` is the in-process QC stand-in override the QC tests pass
|
|
469
|
+
// (src/qc-test-factories.mjs). The bin entry point never passes it, and no
|
|
470
|
+
// flag, environment variable or file can supply one.
|
|
471
|
+
export async function main(argv, { authentication, qcStandIns } = {}) {
|
|
462
472
|
return runInvocation(parseArgs(argv), {
|
|
463
|
-
dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication }),
|
|
473
|
+
dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
|
|
464
474
|
closeOutStaleRunSessions,
|
|
465
475
|
ambientRunSession,
|
|
466
476
|
lifecycleIdentity,
|
|
@@ -719,7 +729,7 @@ export function recordQaStageOutcome(args, result) {
|
|
|
719
729
|
// Which build this verdict judged, and the gates whose browser outcome
|
|
720
730
|
// the doctor's static scan defers to (qaGatePassedForCurrentBuild). A
|
|
721
731
|
// gate that never ran is left out, so silence never reads as a pass.
|
|
722
|
-
evidence: qaStageGateEvidence(verdict, report),
|
|
732
|
+
evidence: qaStageGateEvidence(verdict, report, result),
|
|
723
733
|
// Counts-only: never order ids, refs, emails or URLs (see
|
|
724
734
|
// summarizePurchaseProof). This is what lets `next` tell a real purchase
|
|
725
735
|
// path from a `--test-order off` diagnostic.
|
|
@@ -755,12 +765,21 @@ export function recordQaStageOutcome(args, result) {
|
|
|
755
765
|
}
|
|
756
766
|
}
|
|
757
767
|
|
|
758
|
-
|
|
768
|
+
// QC results are written on every QA record, with the build
|
|
769
|
+
// they were measured against, whether or not a gate outcome is recorded: a
|
|
770
|
+
// record without qc_results reads as captured by an earlier version. The rows
|
|
771
|
+
// come from the QA run's own result; readers re-derive them from the full
|
|
772
|
+
// verdict's qc.* assertions.
|
|
773
|
+
function qaStageGateEvidence(verdict, report, result = null) {
|
|
759
774
|
const gates = {};
|
|
760
775
|
const placeholderText = summarizePlaceholderTextGate(verdict);
|
|
761
776
|
if (placeholderText) gates[QA_GATE_PLACEHOLDER_TEXT_RESIDUE] = placeholderText;
|
|
762
|
-
|
|
763
|
-
|
|
777
|
+
const qc = {
|
|
778
|
+
qc_results: Array.isArray(result?.qc_results) ? result.qc_results : [],
|
|
779
|
+
qc_build_fingerprint: currentBuildFingerprint(report),
|
|
780
|
+
};
|
|
781
|
+
if (!Object.keys(gates).length) return qc;
|
|
782
|
+
return { source_build_fingerprint: currentBuildFingerprint(report), gates, ...qc };
|
|
764
783
|
}
|
|
765
784
|
|
|
766
785
|
// What the auto-end says when the attempt does NOT end the session. Every
|
|
@@ -892,7 +911,7 @@ const PREPARE_MODES = Object.freeze({
|
|
|
892
911
|
// no ambient session, and ahead of help routing, so `login --help`, `demo
|
|
893
912
|
// --help` and `tooling setup --help` reach their own handlers. `argv` is the
|
|
894
913
|
// 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 } = {}) {
|
|
914
|
+
async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = null, sessionHolder = null, argv, authentication, qcStandIns } = {}) {
|
|
896
915
|
// Authentication never recovers/remits run sessions or records argv in a
|
|
897
916
|
// lifecycle journal. Credentials belong only in the user credential store.
|
|
898
917
|
if (command === "login" || command === "logout") {
|
|
@@ -1000,7 +1019,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1000
1019
|
// The live campaign ref check's one read (#533), made before the
|
|
1001
1020
|
// synchronous inspection; see readDoctorLiveCampaign.
|
|
1002
1021
|
const liveCampaign = await readDoctorLiveCampaign(args);
|
|
1003
|
-
const result = doctorCommand(args, { liveCampaign });
|
|
1022
|
+
const result = doctorCommand(args, { liveCampaign, qcStandIns });
|
|
1004
1023
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1005
1024
|
printDoctorTinyPrompt(result, args);
|
|
1006
1025
|
return;
|
|
@@ -1055,6 +1074,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1055
1074
|
}
|
|
1056
1075
|
|
|
1057
1076
|
if (command === "checkpoint") {
|
|
1077
|
+
if (args._[1] === "accept") {
|
|
1078
|
+
const result = await checkpointAcceptCommand(args, { argv, qcStandIns });
|
|
1079
|
+
if (!result) return;
|
|
1080
|
+
writeResult(result, args, result.ok ? 0 : 2);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1058
1083
|
const result = waiveOrRefuse(args, () => checkpointCommand(args), {
|
|
1059
1084
|
gate: optionalString(args.gate) || null,
|
|
1060
1085
|
registeredGates: Object.keys(CHECKPOINT_EVALUATORS),
|
|
@@ -1154,7 +1179,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1154
1179
|
// stage from the current report + doctor state. Existing form with an
|
|
1155
1180
|
// explicit stage (`next build`, `next polish`, etc.) is unchanged.
|
|
1156
1181
|
const stage = args._[1] || null;
|
|
1157
|
-
|
|
1182
|
+
// The QC handoff re-derives QA and Polish results through the check
|
|
1183
|
+
// registry, whose check modules load lazily, so they are loaded here.
|
|
1184
|
+
const qcRederivers = await loadQcRederivers();
|
|
1185
|
+
const result = nextStage(stage, args, ambient, { qcStandIns, qcRederivers });
|
|
1158
1186
|
await observeProgress(args, result, { packageVersion: packageVersion(), resolveKey: resolveCampaignsApiKeySource });
|
|
1159
1187
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1160
1188
|
printNextTinyPrompt(result, args);
|
|
@@ -1893,7 +1921,7 @@ function prepareBuildUnderLock({
|
|
|
1893
1921
|
},
|
|
1894
1922
|
qa: {
|
|
1895
1923
|
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.",
|
|
1924
|
+
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
1925
|
},
|
|
1898
1926
|
notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
|
|
1899
1927
|
};
|
|
@@ -2021,7 +2049,7 @@ function prepareBuildUnderLock({
|
|
|
2021
2049
|
if (!writtenTheme.ok && Array.isArray(writtenTheme.errors) && writtenTheme.errors.length > 0) {
|
|
2022
2050
|
context.theme.warnings = [
|
|
2023
2051
|
...(context.theme.warnings || []),
|
|
2024
|
-
...writtenTheme.errors.map(
|
|
2052
|
+
...writtenTheme.errors.map(themeIssueForReport),
|
|
2025
2053
|
];
|
|
2026
2054
|
}
|
|
2027
2055
|
|
|
@@ -2716,7 +2744,7 @@ export async function polishCaptureCommand(args, options = {}) {
|
|
|
2716
2744
|
});
|
|
2717
2745
|
assertPolishCaptureBindingUnchanged(initialBinding, currentBinding);
|
|
2718
2746
|
|
|
2719
|
-
const merged =
|
|
2747
|
+
const merged = mergePolishCaptureEvidence(currentReport, { pageLoad: capture.page_load, mediaWeight: capture.media_weight });
|
|
2720
2748
|
checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
|
|
2721
2749
|
packet: currentPacket,
|
|
2722
2750
|
report: merged,
|
|
@@ -2811,7 +2839,7 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
|
|
|
2811
2839
|
function checkpointCommand(args) {
|
|
2812
2840
|
const subcommand = args._[1] || "help";
|
|
2813
2841
|
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(", ")}.`);
|
|
2842
|
+
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
2843
|
}
|
|
2816
2844
|
return checkpointWaive(args);
|
|
2817
2845
|
}
|
|
@@ -2934,6 +2962,170 @@ export function checkpointWaive(args) {
|
|
|
2934
2962
|
};
|
|
2935
2963
|
}
|
|
2936
2964
|
|
|
2965
|
+
// `checkpoint accept`: records an operator's accept of a
|
|
2966
|
+
// measured QC warning in report.qc_accepts[], beside the unchanged
|
|
2967
|
+
// measurement. It reads only what is already on disk (doctor recomputed
|
|
2968
|
+
// offline, the persisted doctor sidecar, the Assembly Report and the full QA
|
|
2969
|
+
// verdict the QA stage names) and sends nothing. Every listed result must be a
|
|
2970
|
+
// current accept-eligible warning that was on record before this command;
|
|
2971
|
+
// the check runs again under the report lock immediately before the one
|
|
2972
|
+
// write, and if any ref fails nothing is written.
|
|
2973
|
+
const CHECKPOINT_ACCEPT_VALUE_FLAGS = Object.freeze({
|
|
2974
|
+
packet: "<campaign-runtime.build.json>",
|
|
2975
|
+
result: "<result_id>@<fingerprint12>",
|
|
2976
|
+
reason: "\"<the operator's reason>\"",
|
|
2977
|
+
"expires-at": "<ISO>",
|
|
2978
|
+
"review-condition": "\"<trigger>\"",
|
|
2979
|
+
report: "<json>",
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2982
|
+
// Every value of a flag the operator may repeat, from the raw argv: the shared
|
|
2983
|
+
// parser keeps only the last.
|
|
2984
|
+
function repeatedFlagValues(argv, flag) {
|
|
2985
|
+
const values = [];
|
|
2986
|
+
const tokens = Array.isArray(argv) ? argv : [];
|
|
2987
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
2988
|
+
if (tokens[index] !== `--${flag}`) continue;
|
|
2989
|
+
const next = tokens[index + 1];
|
|
2990
|
+
values.push(next === undefined || next.startsWith("--") ? null : next);
|
|
2991
|
+
}
|
|
2992
|
+
return values;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
function qcAcceptRefusalEnvelope(error) {
|
|
2996
|
+
return {
|
|
2997
|
+
ok: false,
|
|
2998
|
+
refusal_code: error.code,
|
|
2999
|
+
error: error.message,
|
|
3000
|
+
...(error.refused?.length ? { refused: error.refused.map(({ result_ref, refusal_code }) => ({ result_ref, refusal_code })) } : {}),
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
export async function checkpointAcceptCommand(args, { argv = [], qcStandIns = null } = {}) {
|
|
3005
|
+
try {
|
|
3006
|
+
const qcRederivers = await loadQcRederivers();
|
|
3007
|
+
return checkpointAccept(args, { argv, qcStandIns, qcRederivers });
|
|
3008
|
+
} catch (error) {
|
|
3009
|
+
// A typed refusal is an invocation refusal like every untyped one
|
|
3010
|
+
// (refused() marks this invocation's refusal scope), so a refused accept
|
|
3011
|
+
// journals nothing, rendered under --json or thrown without it.
|
|
3012
|
+
const refusal = error instanceof QcAcceptRefusal ? refused(`${error.message} (${error.code})`) : error;
|
|
3013
|
+
if (args.json !== true) throw refusal;
|
|
3014
|
+
const envelope = error instanceof QcAcceptRefusal ? qcAcceptRefusalEnvelope(error) : { ok: false, error: String(error?.message ?? error) };
|
|
3015
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
3016
|
+
console.error(`campaigns-os: ${envelope.error}`);
|
|
3017
|
+
process.exitCode = 1;
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
export function checkpointAccept(args, { argv = [], qcStandIns = null, qcRederivers = null } = {}) {
|
|
3023
|
+
// The command clock; no flag sets accepted_at.
|
|
3024
|
+
const now = new Date().toISOString();
|
|
3025
|
+
const acceptedByValues = repeatedFlagValues(argv, "accepted-by");
|
|
3026
|
+
const acceptedBy = acceptedByValues.length === 1 && isNonEmptyString(args["accepted-by"]) ? args["accepted-by"] : null;
|
|
3027
|
+
if (acceptedByValues.length > 1) throw refused("checkpoint accept takes one --accepted-by: one operator decides one accept command.");
|
|
3028
|
+
for (const [key, placeholder] of Object.entries(CHECKPOINT_ACCEPT_VALUE_FLAGS)) {
|
|
3029
|
+
if (!Object.hasOwn(args, key) || args[key] == null) continue;
|
|
3030
|
+
if (!isNonEmptyString(args[key])) throw refused(`--${key} needs a value: pass --${key} ${placeholder}.`);
|
|
3031
|
+
}
|
|
3032
|
+
if (repeatedFlagValues(argv, "reason").length > 1) throw refused("checkpoint accept takes one --reason per invocation: the operator's reason, verbatim.");
|
|
3033
|
+
const packetPath = resolve(requireArg(args, "packet"));
|
|
3034
|
+
const dryRun = isDryRun(args);
|
|
3035
|
+
const refValues = repeatedFlagValues(argv, "result");
|
|
3036
|
+
if (!refValues.length) throw refused(`checkpoint accept needs at least one --result <result_id>@<fingerprint12>, as the QC handoff of \`${cmd("next")}\` prints it.`);
|
|
3037
|
+
const refs = refValues.map((value) => {
|
|
3038
|
+
const parsed = parseQcResultRef(value);
|
|
3039
|
+
if (!parsed) throw refused(`--result ${JSON.stringify(value)} is not <result_id>@<fingerprint12>; copy the ref the QC handoff of \`${cmd("next")}\` prints.`);
|
|
3040
|
+
return parsed;
|
|
3041
|
+
});
|
|
3042
|
+
const duplicate = refs.find((ref, index) => refs.findIndex((other) => other.id === ref.id) !== index);
|
|
3043
|
+
if (duplicate) throw refused(`--result names "${duplicate.id}" more than once; list each result once.`);
|
|
3044
|
+
const attribution = qcAcceptAttribution({
|
|
3045
|
+
reason: args.reason,
|
|
3046
|
+
acceptedBy,
|
|
3047
|
+
now,
|
|
3048
|
+
expiresAt: args["expires-at"] ?? null,
|
|
3049
|
+
reviewCondition: args["review-condition"] ?? null,
|
|
3050
|
+
});
|
|
3051
|
+
|
|
3052
|
+
const packet = readJson(packetPath);
|
|
3053
|
+
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
3054
|
+
packet,
|
|
3055
|
+
reportPath: args.report == null ? undefined : resolve(args.report),
|
|
3056
|
+
followContextPointer: false,
|
|
3057
|
+
});
|
|
3058
|
+
const { reportPath, targetRepo, doctorOutPath } = workspace;
|
|
3059
|
+
if (!existsSync(reportPath)) throw refused(`checkpoint accept needs an assembly report at ${reportPath}; run prepare-build/start first.`);
|
|
3060
|
+
// An accept only appends. A qc_accepts or evidence value that is not an
|
|
3061
|
+
// array is refused, never replaced, so the value already there stays as it
|
|
3062
|
+
// is (checked on the read before the lock and again under it).
|
|
3063
|
+
const refuseUnappendable = (report) => {
|
|
3064
|
+
for (const field of ["qc_accepts", "evidence"]) {
|
|
3065
|
+
if (isPlainObject(report) && Object.hasOwn(report, field) && !Array.isArray(report[field])) {
|
|
3066
|
+
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.`);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
};
|
|
3070
|
+
|
|
3071
|
+
// Doctor is recomputed offline (no live refs), the spec read the way `next`
|
|
3072
|
+
// reads it, and every leg's results taken from its reader site.
|
|
3073
|
+
const plan = (report) => {
|
|
3074
|
+
const doctor = doctorPacket(packetPath, { reportPath, ...(qcStandIns ? { qcStandIns } : {}) });
|
|
3075
|
+
let spec = null;
|
|
3076
|
+
try {
|
|
3077
|
+
spec = readJsonIfExists(doctor.derived?.spec_path || null);
|
|
3078
|
+
} catch {
|
|
3079
|
+
spec = null;
|
|
3080
|
+
}
|
|
3081
|
+
const { results } = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
|
|
3082
|
+
return planQcAccepts({ refs, results, sidecarPath: doctorOutPath, now, attribution });
|
|
3083
|
+
};
|
|
3084
|
+
// Checked once before taking the lock, so a refusal touches nothing, and
|
|
3085
|
+
// again on the report read under the lock, immediately before the write.
|
|
3086
|
+
const unlocked = readJson(reportPath);
|
|
3087
|
+
refuseUnappendable(unlocked);
|
|
3088
|
+
plan(unlocked);
|
|
3089
|
+
let records = [];
|
|
3090
|
+
const recordAccepts = (report) => {
|
|
3091
|
+
refuseUnappendable(report);
|
|
3092
|
+
records = plan(report);
|
|
3093
|
+
return {
|
|
3094
|
+
...report,
|
|
3095
|
+
qc_accepts: [...(report.qc_accepts ?? []), ...records],
|
|
3096
|
+
evidence: [
|
|
3097
|
+
...(report.evidence ?? []),
|
|
3098
|
+
...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}`),
|
|
3099
|
+
],
|
|
3100
|
+
};
|
|
3101
|
+
};
|
|
3102
|
+
commitWaiverToAssemblyReport(workspace, recordAccepts, {
|
|
3103
|
+
command: "checkpoint accept",
|
|
3104
|
+
staleReason: `A QC accept was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
3105
|
+
}, { dryRun });
|
|
3106
|
+
const accepts = records.map(projectQcAccept);
|
|
3107
|
+
if (dryRun) {
|
|
3108
|
+
return {
|
|
3109
|
+
ok: true,
|
|
3110
|
+
status: "dry_run",
|
|
3111
|
+
dry_run: true,
|
|
3112
|
+
action: "checkpoint-accept",
|
|
3113
|
+
accepts,
|
|
3114
|
+
report_path: reportPath,
|
|
3115
|
+
would_write: reportPath,
|
|
3116
|
+
note: "Dry run: nothing was written and the doctor sidecar was not marked stale. Re-run without --dry-run to record these accepts.",
|
|
3117
|
+
};
|
|
3118
|
+
}
|
|
3119
|
+
return {
|
|
3120
|
+
ok: true,
|
|
3121
|
+
status: "recorded",
|
|
3122
|
+
action: "checkpoint-accept",
|
|
3123
|
+
accepts,
|
|
3124
|
+
report_path: reportPath,
|
|
3125
|
+
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.",
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
|
|
2937
3129
|
// `page-kit sync`: write the CampaignSpec's Store Profile fields and SDK pin
|
|
2938
3130
|
// into the target's _data/campaigns.json entry for the packet's route. This is
|
|
2939
3131
|
// the recovery the two page-kit gates name: a fresh scaffold seeds the entry
|
|
@@ -3810,8 +4002,8 @@ export function pageKitParityCommand(args, options = {}) {
|
|
|
3810
4002
|
const environment = recordedBuildEnvironment(report);
|
|
3811
4003
|
if (environment !== LOCAL_PROOF_BUILD_ENVIRONMENT) {
|
|
3812
4004
|
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
|
|
4005
|
+
? `${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))}.`
|
|
4006
|
+
: `${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
4007
|
}
|
|
3816
4008
|
const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
|
|
3817
4009
|
const expectedSdkVersion = load.status === "ok" && typeof load.entry?.sdk_version === "string" ? load.entry.sdk_version : null;
|
|
@@ -4038,7 +4230,7 @@ function describeDeclaredDepth(purchaseProof) {
|
|
|
4038
4230
|
return `The declared order path depth is "${purchaseProof?.declared_depth || "unspecified"}"`;
|
|
4039
4231
|
}
|
|
4040
4232
|
|
|
4041
|
-
export function nextStage(stage, args, ambient = null) {
|
|
4233
|
+
export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRederivers = null } = {}) {
|
|
4042
4234
|
if (stage !== null && !NEXT_STAGE_ORDER.includes(stage)) {
|
|
4043
4235
|
throw refused(`Unknown next stage: ${stage}. Accepted stages: ${NEXT_STAGE_ORDER.join(", ")}.`);
|
|
4044
4236
|
}
|
|
@@ -4060,8 +4252,19 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4060
4252
|
const doctor = doctorPacket(packetPath, {
|
|
4061
4253
|
contextPath: args.context ? resolve(args.context) : undefined,
|
|
4062
4254
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
4255
|
+
...(qcStandIns ? { qcStandIns } : {}),
|
|
4063
4256
|
});
|
|
4064
4257
|
const prepareBuildGate = doctor.derived?.prepare_build_gate || null;
|
|
4258
|
+
// The CampaignSpec doctor just read (spec.local_path), for the QA stage's
|
|
4259
|
+
// order-bump command. Best-effort: doctor reports an unreadable spec itself.
|
|
4260
|
+
let spec = null;
|
|
4261
|
+
let specReadError = null;
|
|
4262
|
+
try {
|
|
4263
|
+
spec = readJsonIfExists(doctor.derived?.spec_path || null);
|
|
4264
|
+
} catch (error) {
|
|
4265
|
+
spec = null;
|
|
4266
|
+
specReadError = error;
|
|
4267
|
+
}
|
|
4065
4268
|
// #171: `next` recomputes doctor state on every call; persist that fresh
|
|
4066
4269
|
// snapshot so the retained sidecar can never stay a green lie from an
|
|
4067
4270
|
// earlier stage while the campaign degrades (the dogfood target sat
|
|
@@ -4127,13 +4330,26 @@ export function nextStage(stage, args, ambient = null) {
|
|
|
4127
4330
|
} });
|
|
4128
4331
|
if (divergences.length) result.divergences = divergences;
|
|
4129
4332
|
result.gates = buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate, packetPath });
|
|
4130
|
-
result.next_actions = buildNextActions({ result, packetPath, packet, themeGate, polishGate, polishCheckpointGate, prepareBuildGate, ambient, runRecordCloseout, purchaseProof, brandContract: doctor.derived?.brand_contract || null, context: readJsonIfExists(contextPath), targetRepo });
|
|
4333
|
+
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 });
|
|
4334
|
+
// The QC handoff: read from data already loaded plus the
|
|
4335
|
+
// full QA verdict the QA stage names. It adds nothing to errors[],
|
|
4336
|
+
// warnings[] or ready[], and `status` above never reads it. Its accept
|
|
4337
|
+
// command names the report this run read when that is not the default
|
|
4338
|
+
// one (from --report or the Build Context pointer), so it records there.
|
|
4339
|
+
const qc = readCurrentQcResults({ report, doctor, spec, targetRepo, packetPath, reportPath, qcStandIns, rederivers: qcRederivers });
|
|
4340
|
+
result.qc_handoff = buildQcHandoff({ results: qc.results, coverage: qc.coverage, accepts: report?.qc_accepts, packetPath, reportPath: explicitReportPath(reportPath, targetRepo) });
|
|
4131
4341
|
recordNextRecommendation(ambient, result);
|
|
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
|
|
|
@@ -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);
|
|
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 = () => {
|
|
@@ -4637,9 +4853,10 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4637
4853
|
if (result.stage === "setup") {
|
|
4638
4854
|
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
4855
|
} else if (result.stage === "build") {
|
|
4640
|
-
|
|
4856
|
+
const recordBuild = `${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}`;
|
|
4857
|
+
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
4858
|
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
|
|
4859
|
+
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
4860
|
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
4861
|
}
|
|
4645
4862
|
if (themeGate?.status === "blocked") {
|
|
@@ -4661,7 +4878,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
|
|
|
4661
4878
|
} else if (result.stage === "qa") {
|
|
4662
4879
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4663
4880
|
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",
|
|
4881
|
+
push("qa_run", "command", qaRunCommand(packetPath, url), "Run browser + typed-card QA and publish the verdict.");
|
|
4882
|
+
const bumpCart = checkoutOrderBumpCart(spec);
|
|
4883
|
+
if (bumpCart) push("qa_run_bump", "command", qaRunCommand(packetPath, url, bumpCart), `Prove the charged add-on. ${orderBumpRunReason(bumpCart)}`);
|
|
4665
4884
|
} else if (result.stage === "done") {
|
|
4666
4885
|
// #171: run-record closeout is a REQUIRED terminal action, not an
|
|
4667
4886
|
// optional nicety — the dogfood run ended at a terminal stage with the
|
|
@@ -4803,7 +5022,7 @@ Read first:
|
|
|
4803
5022
|
|
|
4804
5023
|
Rules:
|
|
4805
5024
|
- 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
|
|
5025
|
+
- 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.
|
|
4807
5026
|
- Read the selected template family's agentContract and sharedFrontmatterVocabulary before commerce wiring.
|
|
4808
5027
|
- 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
5028
|
- Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
|
|
@@ -4817,17 +5036,17 @@ Rules:
|
|
|
4817
5036
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
4818
5037
|
- 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
5038
|
- 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)}`;
|
|
5039
|
+
- 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.
|
|
5040
|
+
- 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
5041
|
}
|
|
4823
5042
|
|
|
4824
5043
|
// Local proof mode lines for the build prompt: under local-serve the build is
|
|
4825
5044
|
// the development render, recorded as such, and the production render is
|
|
4826
5045
|
// proven by parity before commit.
|
|
4827
|
-
function localProofPromptLines(packet) {
|
|
5046
|
+
function localProofPromptLines(packet, packetPath = "<packet>") {
|
|
4828
5047
|
if (!isLocalServePacket(packet)) return "";
|
|
4829
5048
|
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}
|
|
5049
|
+
- 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}`;
|
|
4831
5050
|
}
|
|
4832
5051
|
|
|
4833
5052
|
function setupPrompt(packetPath, contextPath, reportPath, packet) {
|
|
@@ -4952,9 +5171,57 @@ After deploy succeeds:
|
|
|
4952
5171
|
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
5172
|
}
|
|
4954
5173
|
|
|
4955
|
-
|
|
5174
|
+
// The QA command the QA stage hands over. Given a bump cart it is the
|
|
5175
|
+
// order-bump run: the same command with --cart selecting the base tier and
|
|
5176
|
+
// toggling each declared bump.
|
|
5177
|
+
function qaRunCommand(packetPath, url, bumpCart = null) {
|
|
5178
|
+
return `${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common${bumpCart ? ` --cart ${shellToken(bumpCart.cart)}` : ""}`;
|
|
5179
|
+
}
|
|
5180
|
+
|
|
5181
|
+
// `qa run --test-order common` never puts a checkout order bump in a test
|
|
5182
|
+
// order: the tier planner skips bump rows by design and bump coverage comes
|
|
5183
|
+
// from --cart. So when the spec declares one, `next` names a second run with
|
|
5184
|
+
// the bump in the cart. It reads the one checkout QA drives, with QA's own row
|
|
5185
|
+
// classifiers: QA's test orders run on findPage(topologies, "checkout"), the
|
|
5186
|
+
// first checkout across funnels in array order, each funnel's pages filtered
|
|
5187
|
+
// to enabled and sorted by `order || 0` (extractTopologies). This picks the
|
|
5188
|
+
// same page, and when that checkout declares no bump it returns null rather
|
|
5189
|
+
// than looking at a later funnel: QA never drives a later funnel's checkout,
|
|
5190
|
+
// 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
|
|
5191
|
+
// keeps the page's pre-selected card, which a spec does not name. A checkout
|
|
5192
|
+
// that declares no tier (the cart is filled on an entry page) gets the bump
|
|
5193
|
+
// alone, on whatever selection the entry page made.
|
|
5194
|
+
export function checkoutOrderBumpCart(spec) {
|
|
5195
|
+
const funnels = Array.isArray(spec?.funnels)
|
|
5196
|
+
? spec.funnels
|
|
5197
|
+
: Array.isArray(spec?.funnel_pages) ? [{ pages: spec.funnel_pages }] : [];
|
|
5198
|
+
for (const funnel of funnels) {
|
|
5199
|
+
const checkout = (Array.isArray(funnel?.pages) ? funnel.pages : [])
|
|
5200
|
+
.filter((page) => page && page.enabled !== false)
|
|
5201
|
+
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
|
5202
|
+
.find((page) => page.type === "checkout");
|
|
5203
|
+
if (!checkout) continue;
|
|
5204
|
+
const bumps = declaredOrderBumps(checkout);
|
|
5205
|
+
if (!bumps.length) return null;
|
|
5206
|
+
const base = declaredSelectorTiers(checkout)[0]?.ref || null;
|
|
5207
|
+
return { base, bumps, cart: [base, ...bumps].filter(Boolean).map((ref) => `${ref}:1`).join(",") };
|
|
5208
|
+
}
|
|
5209
|
+
return null;
|
|
5210
|
+
}
|
|
5211
|
+
|
|
5212
|
+
// Why the order-bump run exists, as one sentence for the action and the prompt.
|
|
5213
|
+
function orderBumpRunReason(bumpCart) {
|
|
5214
|
+
const cart = bumpCart.base ? `base tier ${bumpCart.base} plus the bump` : "the bump";
|
|
5215
|
+
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.`;
|
|
5216
|
+
}
|
|
5217
|
+
|
|
5218
|
+
function qaPrompt(packetPath, reportPath, packet, spec = null) {
|
|
4956
5219
|
const url = packet.deploy?.preview_url || packet.deploy?.production_url || "<preview-url>";
|
|
4957
5220
|
const briefPath = packet.build_brief?.normalized_path || "(missing)";
|
|
5221
|
+
const bumpCart = checkoutOrderBumpCart(spec);
|
|
5222
|
+
const bumpCommand = bumpCart
|
|
5223
|
+
? `\n\nOrder bump QA command (proves the charged add-on):\n${qaRunCommand(packetPath, url, bumpCart)}\n${orderBumpRunReason(bumpCart)}`
|
|
5224
|
+
: "";
|
|
4958
5225
|
return `Use next-campaigns-qa for this deployed campaign.
|
|
4959
5226
|
|
|
4960
5227
|
${packet.spec.local_spec_id ? "Local spec ID" : "Map ID"}: ${packet.spec.local_spec_id || packet.spec.map_id}
|
|
@@ -4966,9 +5233,9 @@ Browser install command:
|
|
|
4966
5233
|
${cmd("qa")} install-browser
|
|
4967
5234
|
|
|
4968
5235
|
Node QA command:
|
|
4969
|
-
${
|
|
5236
|
+
${qaRunCommand(packetPath, url)}${bumpCommand}
|
|
4970
5237
|
|
|
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
|
|
5238
|
+
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
5239
|
|
|
4973
5240
|
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
5241
|
|
|
@@ -5800,7 +6067,8 @@ export async function toolingStatusCommand(args, options = {}) {
|
|
|
5800
6067
|
const auth = result.gateway_login;
|
|
5801
6068
|
if (!auth.accounts.length) result.warnings.push(auth.state === "unavailable"
|
|
5802
6069
|
? "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}.
|
|
6070
|
+
: `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_*).`);
|
|
6071
|
+
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
6072
|
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
6073
|
return result;
|
|
5806
6074
|
}
|
|
@@ -8038,6 +8306,8 @@ export function nextTinyPromptLines(result) {
|
|
|
8038
8306
|
if (result.stage !== "qa") return lines;
|
|
8039
8307
|
lines.push("");
|
|
8040
8308
|
lines.push(`Next expected proof: browser QA + typed-card proof. Run: ${cmd("qa")} run --packet <packet> --base-url <url> --browser --test-order common`);
|
|
8309
|
+
const bumpRun = (result.next_actions || []).find((action) => action?.id === "qa_run_bump");
|
|
8310
|
+
if (bumpRun) lines.push(`That run never orders the checkout order bump. Prove the charged add-on with: ${bumpRun.command}`);
|
|
8041
8311
|
lines.push("Localhost on any port is a Development domain (SDK allowed, analytics suppressed). Non-localhost origins still need SDK allowlist confirmation.");
|
|
8042
8312
|
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
8313
|
return lines;
|
|
@@ -8148,6 +8418,10 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
8148
8418
|
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
8149
8419
|
if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
|
|
8150
8420
|
}
|
|
8421
|
+
if (result.action === "checkpoint-accept") {
|
|
8422
|
+
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}` : ""}`);
|
|
8423
|
+
if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
|
|
8424
|
+
}
|
|
8151
8425
|
if (result.action === "record") {
|
|
8152
8426
|
lines.push(`${result.dry_run ? "Would record" : "Recorded"}: ${result.stage}`);
|
|
8153
8427
|
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 +8463,8 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
|
|
|
8189
8463
|
// Directly under the findings they remediate, above the stage picker's
|
|
8190
8464
|
// `Next:` block: the operator reads what is wrong, then what clears it.
|
|
8191
8465
|
lines.push(...doctorRequiredActionLines(result));
|
|
8466
|
+
// `next` only: the QC handoff, after the issues and before the stage picker.
|
|
8467
|
+
if (result.qc_handoff) lines.push(...qcHandoffTextLines(result.qc_handoff));
|
|
8192
8468
|
if (result.next) {
|
|
8193
8469
|
lines.push("Next:");
|
|
8194
8470
|
lines.push(`- ${result.next.stage || "unknown"} (${result.next.owner || result.next.default_skill || "owner unknown"})`);
|