@nextcommerce/campaigns-os 1.53.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 +145 -0
- package/CONTEXT.md +8 -5
- package/README.md +26 -11
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +774 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +10 -0
- package/docs/build-packet.md +45 -22
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/campaigns-os-build-flow.md +1 -1
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +4 -6
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +83 -14
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +2 -1
- package/docs/workflow-findings-sidecar.md +57 -2
- package/package.json +3 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -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-ecosystem.mjs +153 -31
- package/src/campaign-identity.mjs +33 -4
- package/src/cli.mjs +58 -62
- package/src/consent.mjs +3 -2
- package/src/design-source-publication.mjs +1 -1
- package/src/deviation.mjs +2 -1
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +28 -8
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +16 -6
- package/src/invocation.mjs +7 -4
- 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/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +595 -95
- package/src/qa-cart-entry.mjs +29 -6
- package/src/qa-node.mjs +177 -43
- package/src/qa-verdict-publish.mjs +2 -1
- package/src/run-record.mjs +46 -34
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +89 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +15 -3
- package/src/stage-record.mjs +85 -17
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
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 {
|
|
@@ -318,9 +319,10 @@ Usage:
|
|
|
318
319
|
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
319
320
|
[--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
|
|
320
321
|
campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
|
|
321
|
-
campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet
|
|
322
|
+
campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
|
|
322
323
|
campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
|
|
323
324
|
campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
|
|
325
|
+
campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json] # rewrite the version segment of each semver-pinned Campaign Cart loader.js / campaign-cart.css URL (the references standardize's scan finds, @vX.Y.Z or @X.Y.Z) below the target, default the SDK support policy's preferred_minimum; @latest, @main, commit and prerelease refs, and pins at or above the target, are reported and left alone. Preview by default (path:line, old -> new URL, nothing written); --apply writes the files and a change record at .campaign-runtime/sdk-repin.json (campaigns-os-sdk-repin/v0: files touched, reference count, from/to versions) a Run Record can cite. A repo with _data/campaigns.json is refused (exit 2): page-kit sync owns that pin
|
|
324
326
|
campaigns-os standardize --target <campaign-repo> [--family <family>] [--slug <slug>] [--sdk-support-policy <path.json>] [--field-contract <path.json>] [--no-doctor] [--json]
|
|
325
327
|
campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
|
|
326
328
|
campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
|
|
@@ -334,10 +336,10 @@ Usage:
|
|
|
334
336
|
campaigns-os record brief --packet <campaign-runtime.build.json> [--brief <yaml|json>] [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
335
337
|
campaigns-os record spec --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]
|
|
336
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
|
|
337
|
-
campaigns-os record build --packet <campaign-runtime.build.json> [--build-environment <development|production>] [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build (
|
|
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)
|
|
338
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
|
|
339
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
|
|
340
|
-
campaigns-os record deploy --packet <campaign-runtime.build.json> --base-url <served url> [--context <json>] [--report <json>] [--dry-run] [--json] # a local preview (deploy.target local-serve) after polish is recorded: GETs every built page under the loopback URL (the campaign route root), then records deploy.preview_url on the packet and stages.deploy completed with the URL in outputs; refused, writing nothing, when the URL is not loopback or not the route root, a page does not answer 2xx, the build changed since it was recorded, or the theme gate is blocked. --dry-run runs every check, the requests included, and writes nothing
|
|
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
|
|
341
343
|
campaigns-os readback <target-repo-root> [--json] [--packet <path>] [--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] [--findings <path>] # read-only projection of one run's emitted artifacts (packet, doctor output, build context, assembly report, QA verdict, findings export): artifact states, per-artifact freshness against the checkout's HEAD reflog, doctor warning grouping, skip cascades and cross-artifact divergences. Writes nothing, starts no process, touches no network, and records no lifecycle entry; --json emits one campaigns-os-readback/v2 object (docs/readback.md). Exit 2 for a missing target root or a Build Packet set freshness cannot single out.
|
|
342
344
|
campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
|
|
343
345
|
campaigns-os validate-assembly-report --report <json> [--json]
|
|
@@ -355,7 +357,7 @@ Usage:
|
|
|
355
357
|
campaigns-os next deploy --packet <json> --report <json> [--json]
|
|
356
358
|
campaigns-os next qa --packet <json> --report <json> [--json]
|
|
357
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
|
|
358
|
-
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]
|
|
359
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
|
|
360
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
|
|
361
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
|
|
@@ -363,7 +365,7 @@ Usage:
|
|
|
363
365
|
campaigns-os findings harvest --packet <json> [--context <json>] [--report <json>] [--journal <path>] [--run-id <id>] [--write] [--json]
|
|
364
366
|
campaigns-os findings list [--packet <json>] [--journal <path>] [--json]
|
|
365
367
|
campaigns-os findings export [--summary | --json] [--packet <json>] [--journal <path>]
|
|
366
|
-
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]
|
|
367
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.
|
|
368
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.
|
|
369
371
|
|
|
@@ -377,7 +379,7 @@ Usage:
|
|
|
377
379
|
campaigns-os run status [--json] # active session + incomplete stages + deviation count + exact next command; read-only — it never sweeps and never journals
|
|
378
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
|
|
379
381
|
|
|
380
|
-
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>"\`).
|
|
381
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.
|
|
382
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.
|
|
383
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).
|
|
@@ -730,7 +732,7 @@ export function recordQaStageOutcome(args, result) {
|
|
|
730
732
|
outputs: [result.local_path, result.qa_sidecar?.path].filter(isNonEmptyString),
|
|
731
733
|
blockers: verdict.disposition === "blocked" ? failed : [],
|
|
732
734
|
warnings: verdict.disposition === "ready_with_exceptions"
|
|
733
|
-
? ["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."]
|
|
734
736
|
: [],
|
|
735
737
|
// The producer knows its own run id and must restate it, or the stage
|
|
736
738
|
// keeps a previous run's identity beside this run's status and outputs.
|
|
@@ -896,6 +898,7 @@ async function autoEndRunSessionAfterTerminalQa(args, sessionHolder, thrown, imp
|
|
|
896
898
|
|
|
897
899
|
const summary = await closeRunSession(updatedFound, {
|
|
898
900
|
packet,
|
|
901
|
+
closedBy: "qa_auto_end",
|
|
899
902
|
extraArgs,
|
|
900
903
|
silent: true,
|
|
901
904
|
promptForConsent: false,
|
|
@@ -1019,7 +1022,13 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1019
1022
|
args.spec = resolved.specPath;
|
|
1020
1023
|
// `command` rides along for the doctor sidecar's generated_by stamp when
|
|
1021
1024
|
// the mode runs doctor (#312): threaded from here, not re-read from argv.
|
|
1022
|
-
|
|
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
|
+
});
|
|
1023
1032
|
result.spec_source = resolved;
|
|
1024
1033
|
autoStartRunSession(result, args, ambient, sessionHolder);
|
|
1025
1034
|
printPrepareResult(result, args);
|
|
@@ -1031,6 +1040,7 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1031
1040
|
// synchronous inspection; see readDoctorLiveCampaign.
|
|
1032
1041
|
const liveCampaign = await readDoctorLiveCampaign(args);
|
|
1033
1042
|
const result = doctorCommand(args, { liveCampaign, qcStandIns });
|
|
1043
|
+
if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
|
|
1034
1044
|
writeResult(result, args, result.ok ? 0 : 2);
|
|
1035
1045
|
printDoctorTinyPrompt(result, args);
|
|
1036
1046
|
return;
|
|
@@ -1052,7 +1062,25 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1052
1062
|
}
|
|
1053
1063
|
|
|
1054
1064
|
if (command === "sdk") {
|
|
1055
|
-
if (args._[1]
|
|
1065
|
+
if (args._[1] === "repin" && args._.length === 2) {
|
|
1066
|
+
const known = new Set(["_", "target", "target-sdk", "apply", "json"]);
|
|
1067
|
+
for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK repin flag: --${key}`);
|
|
1068
|
+
for (const flag of ["apply", "json"]) {
|
|
1069
|
+
if (args[flag] !== undefined && args[flag] !== true) throw refused(`--${flag} is a boolean flag and takes no value.`);
|
|
1070
|
+
}
|
|
1071
|
+
const { runSdkRepin, formatSdkRepinReport, isRepinTargetVersion } = await import("./sdk-repin.mjs");
|
|
1072
|
+
const targetSdk = args["target-sdk"] === undefined ? null : requireArg(args, "target-sdk");
|
|
1073
|
+
if (targetSdk !== null && !isRepinTargetVersion(targetSdk)) throw refused(`--target-sdk ${targetSdk} is not a released SDK version (x.y.z).`);
|
|
1074
|
+
const result = runSdkRepin({
|
|
1075
|
+
targetRepo: requireArg(args, "target"),
|
|
1076
|
+
targetSdk,
|
|
1077
|
+
apply: args.apply === true,
|
|
1078
|
+
});
|
|
1079
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : formatSdkRepinReport(result));
|
|
1080
|
+
process.exitCode = result.ok ? 0 : 2;
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (args._[1] !== "storage-check" || args._.length !== 2) throw refused("Use: campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json], or campaigns-os sdk repin --target <static-campaign-repo> [--target-sdk <x.y.z>] [--apply] [--json].");
|
|
1056
1084
|
const known = new Set(["_", "target", "target-sdk", "manifest", "scope", "exclude", "json"]);
|
|
1057
1085
|
if (args.json !== undefined && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
1058
1086
|
for (const key of Object.keys(args)) if (!known.has(key)) throw refused(`Unknown SDK storage-check flag: --${key}`);
|
|
@@ -1206,6 +1234,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
|
|
|
1206
1234
|
// command a QA run prints has to agree with the run_id this session will
|
|
1207
1235
|
// later close and remit under.
|
|
1208
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
|
+
}
|
|
1209
1241
|
// nextStage requires a packet. Guard its optional, swallowed progress
|
|
1210
1242
|
// probe explicitly so it cannot construct a refusal in that try block.
|
|
1211
1243
|
if (args._[1] === "run" && result?.verdict && isNonEmptyString(args.packet) && recordQaStageOutcome(args, result)) {
|
|
@@ -1949,7 +1981,7 @@ function prepareBuildUnderLock({
|
|
|
1949
1981
|
},
|
|
1950
1982
|
qa: {
|
|
1951
1983
|
proof_policy: proofPolicy,
|
|
1952
|
-
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.",
|
|
1953
1985
|
},
|
|
1954
1986
|
notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
|
|
1955
1987
|
};
|
|
@@ -3417,7 +3449,7 @@ export function pageKitSyncCommand(args) {
|
|
|
3417
3449
|
result.written = true;
|
|
3418
3450
|
if (stageIsTerminal(report?.stages?.assembly?.status)) {
|
|
3419
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/.`);
|
|
3420
|
-
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)}`;
|
|
3421
3453
|
}
|
|
3422
3454
|
// The retained doctor snapshot (if any) now predates the entry it judged.
|
|
3423
3455
|
// The data file is already written, so a failure here is a warning on the
|
|
@@ -4420,7 +4452,7 @@ export function nextStage(stage, args, ambient = null, { qcStandIns = null, qcRe
|
|
|
4420
4452
|
errors,
|
|
4421
4453
|
warnings,
|
|
4422
4454
|
ready,
|
|
4423
|
-
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." : ""}`,
|
|
4424
4456
|
});
|
|
4425
4457
|
}
|
|
4426
4458
|
stage = picked.stage;
|
|
@@ -4681,7 +4713,7 @@ function themeStarterPaletteAdvisory(themeGate, packetPath, residueState) {
|
|
|
4681
4713
|
+ "runs the template-residue checks at blocker severity, so `qa run` will block on "
|
|
4682
4714
|
+ "template-residue:<page>:style:* rows for the starter call-to-action colour. Decide before QA, "
|
|
4683
4715
|
+ "not after a blocked verdict. Either record an explicit operator waiver — "
|
|
4684
|
-
+ `\`${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>"\` `
|
|
4685
4717
|
+ "— which downgrades those rows to warn severity and keeps the shipped palette visible in the "
|
|
4686
4718
|
+ "verdict; or hand-author the brand layer (write brand-theme.css, list it after next-core.css in "
|
|
4687
4719
|
+ "commerce-page frontmatter styles, rebuild, then run "
|
|
@@ -5042,7 +5074,7 @@ Rules:
|
|
|
5042
5074
|
- Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
|
|
5043
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.
|
|
5044
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.
|
|
5045
|
-
- Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}${isLocalServePacket(packet) ? ` --build-environment ${LOCAL_PROOF_BUILD_ENVIRONMENT}` : ""}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, run \`${cmd("record")} theme --packet ${packetPath}\` after record build: it reads each built commerce page's stylesheet links and records report.theme (status applied, load_order=after-next-core, css_path, commerce_pages, evidence), and refuses, writing nothing, when a page that loads next-core.css does not load the brand layer after it. Never hand-edit report.theme.
|
|
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.
|
|
5046
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)}`;
|
|
5047
5079
|
}
|
|
5048
5080
|
|
|
@@ -5276,7 +5308,7 @@ ${cmd("qa")} install-browser
|
|
|
5276
5308
|
Node QA command:
|
|
5277
5309
|
${qaRunCommand(packetPath, url)}${bumpCommand}
|
|
5278
5310
|
|
|
5279
|
-
Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods and template trust badges, variant media, the template's own promo placeholders (demo timers, promo banners, placeholder voucher codes, exit-pop offers), and pricing presentation against the Campaign Build Brief. Do not flag the source design's own proof, urgency or guarantee elements: they are the merchant's content. Test Orders use global test cards that bypass the payment gateway and create no transactions,
|
|
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.
|
|
5280
5312
|
|
|
5281
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.
|
|
5282
5314
|
|
|
@@ -6602,10 +6634,10 @@ async function promptForFinding(current) {
|
|
|
6602
6634
|
const { createInterface } = await import("node:readline/promises");
|
|
6603
6635
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
6604
6636
|
try {
|
|
6605
|
-
const stage = current.stage || (await rl.question(`Stage (${FINDING_STAGES.join("/")}): `)).trim();
|
|
6606
|
-
const kind = current.kind || (await rl.question(`Kind (${FINDING_KINDS.join("/")}): `)).trim();
|
|
6607
|
-
const summary = current.summary || (await rl.question("Summary: ")).trim();
|
|
6608
|
-
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;
|
|
6609
6641
|
return { stage, kind, summary, details };
|
|
6610
6642
|
} finally {
|
|
6611
6643
|
rl.close();
|
|
@@ -7080,7 +7112,7 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
7080
7112
|
// `run end` is "assemble the Run Record for this session and stop logging to
|
|
7081
7113
|
// it". A throw leaves the session active so the operator can fix the packet
|
|
7082
7114
|
// and re-run `run end`. In text mode run-record prints its own summary.
|
|
7083
|
-
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" });
|
|
7084
7116
|
return { result: summary, exitCode: 0 };
|
|
7085
7117
|
}
|
|
7086
7118
|
|
|
@@ -7095,13 +7127,9 @@ async function runSessionEnd(args, ambient = null, sessionHolder = null) {
|
|
|
7095
7127
|
// `dryRun` entries in src/invocation.mjs and autoEndRunSessionAfterTerminalQa.
|
|
7096
7128
|
export const RUN_RECORD_INHERITABLE_FLAGS = Object.freeze([
|
|
7097
7129
|
"context", "report", "qa-verdict", "journal", "surfaces", "primary-surface", "surface-confidence",
|
|
7098
|
-
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms", "agent-model", "agent-usage-source",
|
|
7099
7130
|
"no-remit", "no-write", "proxy-base", "dry-run", "json",
|
|
7100
7131
|
]);
|
|
7101
7132
|
const RUN_RECORD_BOOLEAN_INHERITABLE_FLAGS = new Set(["no-remit", "no-write", "dry-run", "json"]);
|
|
7102
|
-
const RUN_RECORD_INTEGER_INHERITABLE_FLAGS = new Set([
|
|
7103
|
-
"agent-input-tokens", "agent-output-tokens", "agent-tool-output-tokens", "agent-total-tokens", "agent-elapsed-ms",
|
|
7104
|
-
]);
|
|
7105
7133
|
|
|
7106
7134
|
// The run-record argv that closes `session`: its run_id and journal, the
|
|
7107
7135
|
// packet the closer resolved, and only the inheritable flags of `extraArgs`.
|
|
@@ -7125,7 +7153,7 @@ export function runSessionEndArgs(session, packet, extraArgs = {}) {
|
|
|
7125
7153
|
// session is cleared only AFTER run-record succeeds: on a throw it stays on
|
|
7126
7154
|
// disk, and the error is rethrown unless `onError` takes it, in which case
|
|
7127
7155
|
// the closer returns null.
|
|
7128
|
-
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 } = {}) {
|
|
7129
7157
|
const endArgs = runSessionEndArgs(found.session, packet, extraArgs);
|
|
7130
7158
|
try {
|
|
7131
7159
|
// No internal closeout currently constructs a refusal before its invoking
|
|
@@ -7133,7 +7161,7 @@ async function closeRunSession(found, { packet, extraArgs = {}, silent = false,
|
|
|
7133
7161
|
// run-record, run end validates its own argv first, and QA auto-end runs
|
|
7134
7162
|
// after QA persistence. Keep the scope at this boundary so a future
|
|
7135
7163
|
// closeout refusal swallowed by onError cannot mark the outer invocation.
|
|
7136
|
-
const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent }));
|
|
7164
|
+
const summary = await runWithRefusalScope(() => runRecordCommand(endArgs, found, { silent, promptForConsent, closedBy }));
|
|
7137
7165
|
// Clearing the session is a write like any other, so a closer carrying
|
|
7138
7166
|
// --dry-run leaves it open: the operator sees the record the close would
|
|
7139
7167
|
// assemble and can still close for real afterwards.
|
|
@@ -7214,6 +7242,7 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
|
|
|
7214
7242
|
if (packet && existsSync(packet)) {
|
|
7215
7243
|
const summary = await closeRunSession(stale, {
|
|
7216
7244
|
packet,
|
|
7245
|
+
closedBy: "stale_sweep",
|
|
7217
7246
|
extraArgs: { ...inherited, json: true },
|
|
7218
7247
|
silent: true,
|
|
7219
7248
|
promptForConsent: false,
|
|
@@ -7242,12 +7271,11 @@ async function closeOutStaleRunSession(rootDir, inherited = {}) {
|
|
|
7242
7271
|
// run-record.mjs to assemble the manifest, then (consent-gated, non-fatal)
|
|
7243
7272
|
// remit it. Capture is ALWAYS local; consent gates only the remit. See
|
|
7244
7273
|
// docs/workflow-findings-sidecar.md.
|
|
7245
|
-
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true } = {}) {
|
|
7274
|
+
async function runRecordCommand(args, ambient = null, { silent = false, promptForConsent = true, closedBy = "manual" } = {}) {
|
|
7246
7275
|
const packetPath = resolve(requireArg(args, "packet"));
|
|
7247
7276
|
if (args["new-run"] === true && optionalString(args["run-id"])) {
|
|
7248
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.");
|
|
7249
7278
|
}
|
|
7250
|
-
const agentUsage = refusing(() => parseAgentUsageArgs(args));
|
|
7251
7279
|
// --dry-run assembles the record and shows it, then writes and sends
|
|
7252
7280
|
// nothing. It differs from --no-write, which skips the assembly's reads
|
|
7253
7281
|
// as well; combining the two is allowed and still writes nothing.
|
|
@@ -7484,6 +7512,7 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
7484
7512
|
packageVersion: packageVersion(),
|
|
7485
7513
|
...toolkitProvenance({ silent }),
|
|
7486
7514
|
qaVerdictPublish,
|
|
7515
|
+
closedBy,
|
|
7487
7516
|
command: "run-record",
|
|
7488
7517
|
argvShape: argvShape(args),
|
|
7489
7518
|
consent: { state: consent.state, source: consent.source },
|
|
@@ -7505,7 +7534,6 @@ async function runRecordCommand(args, ambient = null, { silent = false, promptFo
|
|
|
7505
7534
|
primarySurface: optionalString(args["primary-surface"]),
|
|
7506
7535
|
surfaceConfidence: optionalString(args["surface-confidence"]),
|
|
7507
7536
|
lifecycle,
|
|
7508
|
-
agentUsage,
|
|
7509
7537
|
});
|
|
7510
7538
|
|
|
7511
7539
|
const shouldAttemptRemit = !remitDisabled && consent.state === "on";
|
|
@@ -7867,9 +7895,7 @@ function parseRunRecordSurfaces(value) {
|
|
|
7867
7895
|
|
|
7868
7896
|
function validateRunRecordArgv(args) {
|
|
7869
7897
|
for (const flag of RUN_RECORD_INHERITABLE_FLAGS) {
|
|
7870
|
-
|
|
7871
|
-
// their non-negative-integer diagnostics for bare and blank values.
|
|
7872
|
-
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);
|
|
7873
7899
|
}
|
|
7874
7900
|
if (Object.hasOwn(args, "run-id")) requireArg(args, "run-id");
|
|
7875
7901
|
if (Object.hasOwn(args, "new-run") && args["new-run"] !== true) {
|
|
@@ -7879,41 +7905,11 @@ function validateRunRecordArgv(args) {
|
|
|
7879
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.");
|
|
7880
7906
|
}
|
|
7881
7907
|
return {
|
|
7882
|
-
agentUsage: refusing(() => parseAgentUsageArgs(args)),
|
|
7883
7908
|
dryRun: isDryRun(args),
|
|
7884
7909
|
parsedSurfaces: parseRunRecordSurfaces(args.surfaces),
|
|
7885
7910
|
};
|
|
7886
7911
|
}
|
|
7887
7912
|
|
|
7888
|
-
function parseAgentUsageArgs(args) {
|
|
7889
|
-
const fields = {
|
|
7890
|
-
"agent-input-tokens": "input_tokens",
|
|
7891
|
-
"agent-output-tokens": "output_tokens",
|
|
7892
|
-
"agent-tool-output-tokens": "tool_output_tokens",
|
|
7893
|
-
"agent-total-tokens": "total_tokens",
|
|
7894
|
-
"agent-elapsed-ms": "elapsed_ms",
|
|
7895
|
-
};
|
|
7896
|
-
const usage = {};
|
|
7897
|
-
for (const [flag, field] of Object.entries(fields)) {
|
|
7898
|
-
if (!(flag in args)) continue;
|
|
7899
|
-
usage[field] = parseNonNegativeIntegerFlag(args[flag], flag);
|
|
7900
|
-
}
|
|
7901
|
-
if (isNonEmptyString(args["agent-model"])) usage.model = args["agent-model"].trim();
|
|
7902
|
-
if (isNonEmptyString(args["agent-usage-source"])) usage.source = args["agent-usage-source"].trim();
|
|
7903
|
-
return Object.keys(usage).length ? usage : null;
|
|
7904
|
-
}
|
|
7905
|
-
|
|
7906
|
-
function parseNonNegativeIntegerFlag(value, flag) {
|
|
7907
|
-
if (value === true || value === false || value == null || String(value).trim() === "") {
|
|
7908
|
-
throw new Error(`--${flag} requires a non-negative integer value.`);
|
|
7909
|
-
}
|
|
7910
|
-
const parsed = Number(value);
|
|
7911
|
-
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
7912
|
-
throw new Error(`--${flag} must be a non-negative integer.`);
|
|
7913
|
-
}
|
|
7914
|
-
return parsed;
|
|
7915
|
-
}
|
|
7916
|
-
|
|
7917
7913
|
function packageVersion() {
|
|
7918
7914
|
return readJson(join(ROOT, "package.json")).version;
|
|
7919
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/directory-lock.mjs
CHANGED
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
// to an older writer that may still be alive between its mkdir and its owner
|
|
19
19
|
// write (#501). A waiter refuses it after a short grace, leaving it for the
|
|
20
20
|
// documented offline procedure. (See publishStagedDirectory for the one
|
|
21
|
-
// mixed-version race this cannot close, tracked in #514.)
|
|
21
|
+
// mixed-version race this cannot close, tracked in #514.) Anything else at the
|
|
22
|
+
// lock path, a file or a directory holding other entries, is not a lock at
|
|
23
|
+
// all: it is refused by name and never removed (#514).
|
|
24
|
+
//
|
|
25
|
+
// Staging and released siblings a dead process left beside the lock are swept
|
|
26
|
+
// by the next holder, under the lock; a sibling whose owner is alive or
|
|
27
|
+
// unreadable is left alone (#514).
|
|
22
28
|
//
|
|
23
29
|
// The lock is reentrant for its holder: code running inside `fn` (in the
|
|
24
30
|
// same async context) that asks for the same lock enters directly instead of
|
|
@@ -26,7 +32,7 @@
|
|
|
26
32
|
// stage writers that take it too.
|
|
27
33
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
34
|
import { randomBytes } from "node:crypto";
|
|
29
|
-
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
30
36
|
import { basename, dirname, join, resolve } from "node:path";
|
|
31
37
|
|
|
32
38
|
const heldLocks = new AsyncLocalStorage();
|
|
@@ -39,6 +45,47 @@ const exists = (path) => {
|
|
|
39
45
|
try { lstatSync(path); return true; } catch { return false; }
|
|
40
46
|
};
|
|
41
47
|
|
|
48
|
+
// True only when the owner record names a process that no longer exists. An
|
|
49
|
+
// unreadable record, or a pid this process may not signal, is not dead.
|
|
50
|
+
const ownerIsDead = (owner) => {
|
|
51
|
+
if (!(Number.isInteger(owner?.pid) && owner.pid > 0 && typeof owner.token === "string")) return false;
|
|
52
|
+
try { process.kill(owner.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
|
|
57
|
+
// The siblings this module creates beside a lock and normally removes itself:
|
|
58
|
+
// a crash leaves staging and recovery-staging ones, a release whose tomb could
|
|
59
|
+
// not be put back leaves a released one. Each carries an owner.json.
|
|
60
|
+
function siblingPattern(path) {
|
|
61
|
+
return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Called only by the holder of the lock, so no two sweeps race, and a
|
|
65
|
+
// sibling is removed only when its owner is a dead process: a live owner may
|
|
66
|
+
// still be staging or releasing, and an unreadable one may be between its
|
|
67
|
+
// mkdir and its owner write. Best effort; a failure leaves the sibling.
|
|
68
|
+
function sweepDeadSiblings(path) {
|
|
69
|
+
const parent = dirname(path);
|
|
70
|
+
const pattern = siblingPattern(path);
|
|
71
|
+
let entries;
|
|
72
|
+
try { entries = readdirSync(parent); } catch { return; }
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!pattern.test(entry)) continue;
|
|
75
|
+
const sibling = join(parent, entry);
|
|
76
|
+
try {
|
|
77
|
+
const stat = lstatSync(sibling);
|
|
78
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
|
|
79
|
+
if (!ownerIsDead(readOwner(join(sibling, "owner.json")))) continue;
|
|
80
|
+
rmSync(sibling, { recursive: true, force: true });
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// What a lock directory may hold besides its owner record: the recovery
|
|
86
|
+
// claim. A directory without owner.json that holds anything else is not a lock.
|
|
87
|
+
const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
|
|
88
|
+
|
|
42
89
|
// The reentrancy key names the lock directory through its real parent, so a
|
|
43
90
|
// holder that reached the target through a symlink still recognizes itself.
|
|
44
91
|
function lockKey(path) {
|
|
@@ -106,9 +153,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
106
153
|
try {
|
|
107
154
|
const stat = lstatSync(path);
|
|
108
155
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
109
|
-
|
|
110
|
-
if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
|
|
111
|
-
try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
|
|
156
|
+
return ownerIsDead(readOwner(ownerPath));
|
|
112
157
|
} catch {
|
|
113
158
|
return false;
|
|
114
159
|
}
|
|
@@ -179,7 +224,32 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
179
224
|
return current - start >= budgetMs;
|
|
180
225
|
};
|
|
181
226
|
const fail = (error) => unavailable(error);
|
|
182
|
-
|
|
227
|
+
// Names what occupies the lock path when it is not a lock, else null. A
|
|
228
|
+
// file there, or a directory with no owner record that holds entries a lock
|
|
229
|
+
// never has, will not clear by waiting and is never removed here.
|
|
230
|
+
const obstruction = () => {
|
|
231
|
+
let stat;
|
|
232
|
+
try { stat = lstatSync(path); } catch { return null; }
|
|
233
|
+
if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
|
|
234
|
+
if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
|
|
235
|
+
if (exists(ownerPath)) return null;
|
|
236
|
+
let foreign;
|
|
237
|
+
try { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
|
|
238
|
+
if (foreign.length === 0) return null;
|
|
239
|
+
const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
|
|
240
|
+
return `a directory that is not a lock (no owner.json; it holds ${shown})`;
|
|
241
|
+
};
|
|
242
|
+
// The refusal once waiting ends, telling a held lock, an ownerless lock and
|
|
243
|
+
// a non-lock obstruction apart.
|
|
244
|
+
const contended = () => {
|
|
245
|
+
const found = obstruction();
|
|
246
|
+
if (found) {
|
|
247
|
+
return Object.assign(new Error(`Lock path is occupied by ${found}: ${path}`), { code: "ENOTLOCK", obstruction: found });
|
|
248
|
+
}
|
|
249
|
+
if (ownerless()) return Object.assign(new Error(`Lock has no owner record: ${path}`), { code: "EEXIST" });
|
|
250
|
+
return Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
|
|
251
|
+
};
|
|
252
|
+
const sweep = () => sweepDeadSiblings(path);
|
|
183
253
|
|
|
184
254
|
const release = () => {
|
|
185
255
|
if (readOwner(ownerPath)?.token !== token) return;
|
|
@@ -193,7 +263,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
193
263
|
}
|
|
194
264
|
};
|
|
195
265
|
|
|
196
|
-
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
|
|
266
|
+
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
|
|
197
267
|
}
|
|
198
268
|
|
|
199
269
|
function reentrantKey(path) {
|
|
@@ -232,6 +302,7 @@ export async function withDirectoryLock(path, fn, options) {
|
|
|
232
302
|
await sleep(20);
|
|
233
303
|
}
|
|
234
304
|
try {
|
|
305
|
+
lock.sweep();
|
|
235
306
|
return await runHolding(key, lock.token, fn);
|
|
236
307
|
} finally {
|
|
237
308
|
lock.release();
|
|
@@ -263,6 +334,7 @@ export function withDirectoryLockSync(path, fn, options) {
|
|
|
263
334
|
(options.sleep ?? sleepSync)(20);
|
|
264
335
|
}
|
|
265
336
|
try {
|
|
337
|
+
lock.sweep();
|
|
266
338
|
return runHolding(key, lock.token, fn);
|
|
267
339
|
} finally {
|
|
268
340
|
lock.release();
|