@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/CONTEXT.md +8 -5
  3. package/README.md +26 -11
  4. package/compatibility.json +1 -1
  5. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  6. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  7. package/contracts/commerce-surface-catalog.json +27 -27
  8. package/contracts/effects.v1.json +96 -37
  9. package/contracts/release-ledger.json +774 -0
  10. package/contracts/supported-surface.json +4 -4
  11. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  12. package/docs/brand-theme-bridge.md +10 -0
  13. package/docs/build-packet.md +45 -22
  14. package/docs/campaign-standardization-report.md +32 -0
  15. package/docs/campaigns-os-build-flow.md +1 -1
  16. package/docs/design-source-package.md +7 -3
  17. package/docs/effects.md +4 -6
  18. package/docs/local-setup.md +1 -1
  19. package/docs/orientation-contract-reference.md +1 -1
  20. package/docs/progress-snapshots.md +3 -1
  21. package/docs/qa-and-test-orders.md +83 -14
  22. package/docs/runtime-readiness.md +1 -1
  23. package/docs/sdk-storage-compatibility.md +15 -0
  24. package/docs/skills-revision.md +10 -10
  25. package/docs/supported-surface.md +2 -1
  26. package/docs/workflow-findings-sidecar.md +57 -2
  27. package/package.json +3 -1
  28. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  29. package/schemas/campaigns-os-run-record.v0.schema.json +20 -15
  30. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  31. package/skills/campaign-readback-classification/SKILL.md +3 -3
  32. package/skills/campaign-run-evidence/SKILL.md +3 -3
  33. package/skills/contribution-intake/SKILL.md +3 -3
  34. package/skills/next-campaigns-build/SKILL.md +3 -3
  35. package/skills/next-campaigns-os/SKILL.md +3 -3
  36. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  37. package/skills/next-campaigns-polish/SKILL.md +3 -3
  38. package/skills/next-campaigns-qa/SKILL.md +4 -4
  39. package/skills.json +10 -10
  40. package/src/adapter-decision-contract.mjs +26 -12
  41. package/src/brand-theme.mjs +44 -8
  42. package/src/campaign-ecosystem.mjs +153 -31
  43. package/src/campaign-identity.mjs +33 -4
  44. package/src/cli.mjs +58 -62
  45. package/src/consent.mjs +3 -2
  46. package/src/design-source-publication.mjs +1 -1
  47. package/src/deviation.mjs +2 -1
  48. package/src/directory-lock.mjs +79 -7
  49. package/src/doctor/checks.mjs +28 -8
  50. package/src/doctor/inspect.mjs +13 -0
  51. package/src/doctor/next-step.mjs +16 -6
  52. package/src/invocation.mjs +7 -4
  53. package/src/lifecycle.mjs +88 -6
  54. package/src/local-preview-policy.mjs +6 -0
  55. package/src/login.mjs +2 -1
  56. package/src/polish-browser.mjs +8 -2
  57. package/src/polish-node.mjs +6 -1
  58. package/src/polish-readability.mjs +2 -2
  59. package/src/progress-node.mjs +3 -2
  60. package/src/qa-browser.mjs +595 -95
  61. package/src/qa-cart-entry.mjs +29 -6
  62. package/src/qa-node.mjs +177 -43
  63. package/src/qa-verdict-publish.mjs +2 -1
  64. package/src/run-record.mjs +46 -34
  65. package/src/sdk-attribute-index.mjs +111 -23
  66. package/src/sdk-markup.mjs +89 -10
  67. package/src/sdk-repin.mjs +282 -0
  68. package/src/stage-ledger.mjs +15 -3
  69. package/src/stage-record.mjs +85 -17
  70. package/src/standardization-report.mjs +26 -1
  71. 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 (--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
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>] [--agent-total-tokens <n>] [--agent-elapsed-ms <n>] [--proxy-base <url>] [--no-remit] [--no-write] [--dry-run] [--list] [--json]
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\` / \`qa run --theme-waive "<reason>"\`).
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 completed with explicitly attributed exceptions; inspect the verdict artifact."]
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
- const result = await recorder.time("prepare-build", () => prepareBuild(args, { ...mode, command, specInput, publishSpec, sourceKind, wrapperPolicyFlag, orderPathDepthFlag }));
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] !== "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].");
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. 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.",
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: set stages.assembly.status back to "pending" on the Assembly Report and run ${cmd("next")} --packet ${shellToken(packetPath)}`;
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. If you need to re-run a stage, set its status back to "pending" in the report and call \`next\` again. If a run session is active, finish it with \`${cmd("run")} end\` so the Run Record is assembled and the session closes.`,
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, 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.
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
- // The integer flags are checked below by parseAgentUsageArgs, which keeps
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(["start", "prepare-build", "theme", "polish", "qa", "run-record"]));
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;
@@ -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
- const current = readOwner(ownerPath);
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
- const contended = () => Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
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();