@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
@@ -108,7 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
108
108
  // `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
109
109
  // node engine in package.json without type-stripping. build runs on `prepare`,
110
110
  // so a fresh install (including the git-ref consumer) always has dist.
111
- import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
111
+ import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
112
112
  import { cmd, asInvocation } from "../install-invocation.mjs";
113
113
  import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
114
114
  import {
@@ -440,7 +440,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
440
440
  {
441
441
  id: SDK_MARKUP,
442
442
  phase: "built-output",
443
- run: ({ packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived),
443
+ run: ({ spec, packet, errors, warnings, ready, derived }) => validateSdkMarkup(packet, errors, warnings, ready, derived, spec),
444
444
  },
445
445
  {
446
446
  id: SCRIPT_SYNTAX,
@@ -832,7 +832,7 @@ function validateAdapterContracts(packet, packetPath, spec, errors, warnings, re
832
832
  const packetContract = packet.source_html?.adapter_contract;
833
833
  validateAdapterDecisionShape(packetContract, "source_html.adapter_contract", warnings, ready, { addIssue });
834
834
  validateAdapterSourceFiles({
835
- decisions: packetContract,
835
+ decisions: buildState.report?.adapter_decisions || buildState.context?.adapter_decisions || packetContract,
836
836
  sourceRoot: resolveFromFile(packetPath, packet.source_html?.root),
837
837
  pages: packet.source_html?.pages || [],
838
838
  warnings,
@@ -2147,6 +2147,10 @@ export function validateCampaignIdentity(packet, errors, ready, derived, spec =
2147
2147
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2148
2148
  },
2149
2149
  pages,
2150
+ expectedApiKey: (() => {
2151
+ const resolved = resolveCampaignsApiKeySource(packet, null, process.env, { spec });
2152
+ return resolved.key ? { key: resolved.key, source: resolved.origin } : null;
2153
+ })(),
2150
2154
  errors,
2151
2155
  ready,
2152
2156
  derived,
@@ -2290,8 +2294,8 @@ function readBoundedScript(realSiteRoot, path, maxBytes) {
2290
2294
  }
2291
2295
  }
2292
2296
 
2293
- function recordCampaignIdentityGate({ subject, pages, errors, ready, derived }) {
2294
- const gate = evaluateCampaignIdentity({ subject, pages });
2297
+ function recordCampaignIdentityGate({ subject, pages, expectedApiKey = null, errors, ready, derived }) {
2298
+ const gate = evaluateCampaignIdentity({ subject, pages, expectedApiKey });
2295
2299
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
2296
2300
 
2297
2301
  if (gate.status === "blocked") {
@@ -2315,17 +2319,23 @@ function recordCampaignIdentityGate({ subject, pages, errors, ready, derived })
2315
2319
  // Static SDK markup checks (#303). Every doctor invocation, both entry points,
2316
2320
  // filesystem enumeration, blocking regardless of stage status — the same
2317
2321
  // contract as the two gates above, for the same reasons.
2318
- function validateSdkMarkup(packet, errors, warnings, ready, derived) {
2322
+ function validateSdkMarkup(packet, errors, warnings, ready, derived, spec = null) {
2319
2323
  const targetRepo = derived.target_repo;
2320
2324
  const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
2321
2325
  const siteRoot = targetRepo && publicRouteSlug ? join(targetRepo, "_site", publicRouteSlug) : null;
2322
2326
  const scope = siteRoot && existsSync(siteRoot) ? resolveBuiltSiteScope(targetRepo, { slug: publicRouteSlug }) : null;
2327
+ const declaredTypes = new Map(activeSpecPages(spec).filter((page) => ["receipt", "thankyou"].includes(page.type)).map((page) => [
2328
+ builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived), "receipt",
2329
+ ]));
2323
2330
  recordSdkMarkupGate({
2324
2331
  subject: {
2325
2332
  public_route_slug: publicRouteSlug || null,
2326
2333
  site_root: siteRoot && targetRepo ? relFromDir(targetRepo, siteRoot) : null,
2327
2334
  },
2328
- pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo) : [],
2335
+ pages: scope?.ok ? collectBuiltPageIdentityInputs(scope, targetRepo).map((page) => ({
2336
+ ...page,
2337
+ page_type: declaredTypes.get(resolve(targetRepo, page.file)) || null,
2338
+ })) : [],
2329
2339
  errors,
2330
2340
  warnings,
2331
2341
  ready,
@@ -2522,7 +2532,7 @@ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, wa
2522
2532
  }
2523
2533
 
2524
2534
  function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
2525
- const gate = evaluateSdkMarkup({ subject, pages });
2535
+ const gate = evaluateSdkMarkup({ subject, pages, sdkVersion: campaignSdkPin(derived?.target_repo, subject?.public_route_slug) });
2526
2536
  if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
2527
2537
 
2528
2538
  if (gate.status === "not_applicable") {
@@ -2553,6 +2563,16 @@ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived
2553
2563
  return gate;
2554
2564
  }
2555
2565
 
2566
+ // The campaign's SDK pin from the target's campaigns.json entry, when it is an
2567
+ // exact released version; else null. The SDK markup check reads a page's own
2568
+ // loader pin first and falls back to this one.
2569
+ function campaignSdkPin(targetRepo, publicRouteSlug) {
2570
+ if (!targetRepo || !publicRouteSlug) return null;
2571
+ const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
2572
+ const version = load.status === "ok" ? load.entry?.sdk_version : null;
2573
+ return isReleasedSdkVersion(version) ? version : null;
2574
+ }
2575
+
2556
2576
  // Route drift: a CampaignSpec page whose declared route has no built page at
2557
2577
  // that path. page-kit derives the public route from the source FILENAME, so a
2558
2578
  // file named presell-running.html builds at /presell-running/ even if the spec
@@ -5,6 +5,7 @@ import { withHtmlScanSnapshot } from "../html-scan.mjs";
5
5
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { shellToken } from "../shell-token.mjs";
8
+ import { cmd } from "../install-invocation.mjs";
8
9
  import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
9
10
  import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
10
11
  import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
@@ -244,6 +245,18 @@ export function doctorBuiltOutput(args) {
244
245
  qc_results: [],
245
246
  };
246
247
  ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
248
+ // #583: --base-url reads like a check of the deployed site, but these checks
249
+ // read only the local built files. Say so wherever the URL is given, and
250
+ // name the command that does load the served pages.
251
+ if (baseUrl) {
252
+ derived.base_url = { value: baseUrl, fetched: false, recorded_as: "deploy.preview_url" };
253
+ // Without --family there is no value to print that the CLI would accept,
254
+ // so the command is printed only when it is runnable as shown.
255
+ const servedCheck = family
256
+ ? `To check the served pages, run ${cmd("qa", `run --site ${shellToken(String(args.built || args.site))} --base-url ${shellToken(baseUrl)} --family ${shellToken(family)} --browser`)}.`
257
+ : `To check the served pages, run ${cmd("qa")} run --site with this --base-url and the campaign's --family, plus --browser.`;
258
+ ready.push(`Checked the local built files only: --base-url ${baseUrl} was not fetched; it only fills deploy.preview_url in the minimal Build Packet. ${servedCheck}`);
259
+ }
247
260
 
248
261
  const resolution = resolveBrandContractOnce(derived, family);
249
262
  let brandContract = null;
@@ -1,9 +1,9 @@
1
1
  // The next step doctor recommends, and the gate issues `next` reads from doctor.
2
2
  import { campaignIdentitiesMatch } from "../spec-source-identity.mjs";
3
- import { CARRIED_FORWARD } from "../local-preview-policy.mjs";
3
+ import { polishCarriedForwardForLadder } from "../local-preview-policy.mjs";
4
4
  import { resolve } from "node:path";
5
5
  import { orderPathDepthDriftText } from "../proof-policy.mjs";
6
- import { anyAssemblyReportStageBlocked } from "../stage-ledger.mjs";
6
+ import { anyAssemblyReportStageBlocked, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "../stage-ledger.mjs";
7
7
  import {
8
8
  SOURCE_PREP_DOCUMENT_WRAPPER,
9
9
  SOURCE_PREP_FRONTMATTER_RESIDUE,
@@ -16,7 +16,7 @@ import {
16
16
  stageIsBlocked,
17
17
  stageIsTerminal,
18
18
  } from "../orchestration-stage-contract.mjs";
19
- import { evaluatePolishGate } from "../polish-gate.mjs";
19
+ import { currentBuildFingerprint, evaluatePolishGate } from "../polish-gate.mjs";
20
20
  import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
21
21
  import { cmd } from "../install-invocation.mjs";
22
22
  import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
@@ -473,9 +473,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
473
473
  }
474
474
 
475
475
  for (const cliStage of NEXT_STAGE_ORDER) {
476
- // On the local preview, missing polish is carried forward as a warning
477
- // (local-preview-policy.mjs): the ladder moves on to deploy and QA.
478
- if (cliStage === "polish" && polishGate.status === CARRIED_FORWARD) continue;
476
+ // A first local preview may carry missing Polish forward. A theme waiver
477
+ // or prior capture makes the recorded Polish stage owed again.
478
+ if (cliStage === "polish" && polishCarriedForwardForLadder(report, polishGate)) continue;
479
479
  const reportKey = reportKeyForCliStage(cliStage);
480
480
  const stage = report.stages[reportKey];
481
481
  if (!stage) {
@@ -500,6 +500,10 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
500
500
  reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
501
501
  };
502
502
  }
503
+ if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
504
+ && !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
505
+ return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
506
+ }
503
507
  // A terminal QA status is not the same claim as purchase proof. QA finalizes
504
508
  // a verdict and records a terminal status even when no order path ran, so a
505
509
  // `--test-order off` diagnostic used to carry the pipeline to "done" against
@@ -711,6 +715,12 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
711
715
  const fallbackAction = picked.stage === "done"
712
716
  ? `All stages are recorded as terminal; run ${cmd("next")} to confirm the closeout actions.`
713
717
  : `Run ${command}.`;
718
+ if (picked.stage === "done") {
719
+ actions.unshift(fallbackAction);
720
+ if (report?.stages?.qa?.status === "completed_with_warnings") {
721
+ actions.push("QA passed with exceptions. Report them to the operator; do not clear or waive them, or change markup just to make them pass.");
722
+ }
723
+ }
714
724
  return {
715
725
  stage: picked.stage,
716
726
  status: blocked ? "blocked" : readinessStatus(warnings, derived),
@@ -54,7 +54,7 @@ const COMMANDS = frozen({
54
54
  logout: { class: "auth" },
55
55
  demo: { class: "inline" },
56
56
  tooling: { subcommands: ["diagnose", "setup", "status"] },
57
- sdk: { subcommands: ["storage-check"] },
57
+ sdk: { subcommands: ["repin", "storage-check"] },
58
58
  readback: { class: "projection" },
59
59
  start: { sweepRoot: "target" },
60
60
  "prepare-build": { sweepRoot: "target" },
@@ -86,6 +86,8 @@ const SUBCOMMAND_OVERRIDES = frozen({
86
86
  "tooling diagnose": { class: "inline" },
87
87
  "tooling setup": { class: "inline" },
88
88
  "sdk storage-check": { class: "inspection" },
89
+ // Preview by default: only --apply writes, so only --apply journals.
90
+ "sdk repin": { journalExemptWhen: { given: "target", unlessBare: "apply" } },
89
91
  "theme waive": { dryRun: true },
90
92
  "checkpoint accept": { dryRun: true },
91
93
  "checkpoint waive": { dryRun: true },
@@ -130,14 +132,15 @@ export function resolveInvocationPolicy(command, args) {
130
132
  const steps = CLASS_STEPS[rule.class];
131
133
  const noWrite = args["no-write"] === true;
132
134
  const implementsDryRun = rule.dryRun === true;
133
- const sweepSuppressed = optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
135
+ const help = Boolean(args.help);
136
+ const sweepSuppressed = help || optsOutOfRunSession(args) || noWrite || (Object.hasOwn(args, "dry-run") && implementsDryRun);
134
137
  const inspection = Boolean(rule.journalExemptWhen && args[rule.journalExemptWhen.given] && args[rule.journalExemptWhen.unlessBare] !== true);
135
138
  return Object.freeze({
136
139
  class: rule.class,
137
140
  wrapper: steps.includes("wrapper"), ambient: steps.includes("ambient"),
138
141
  sweepRoot: steps.includes("sweep") && !sweepSuppressed ? rule.sweepRoot || null : null,
139
- journalExempt: !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
140
- implementsDryRun, autoEnd: rule.autoEnd === true,
142
+ journalExempt: help || !steps.includes("journal") || rule.journalExempt === true || noWrite || (args["dry-run"] === true && implementsDryRun) || inspection,
143
+ implementsDryRun, autoEnd: !help && rule.autoEnd === true,
141
144
  });
142
145
  }
143
146
 
package/src/lifecycle.mjs CHANGED
@@ -66,6 +66,7 @@ export const LIFECYCLE_JOURNAL_REL_PATH = ".campaign-runtime/command-lifecycle.j
66
66
  // from anywhere.
67
67
  export const REFUSED_INVOCATION = "refused_invocation";
68
68
  const refusalScope = new AsyncLocalStorage();
69
+ const invocationScope = new AsyncLocalStorage();
69
70
 
70
71
  /** Run `fn` with its own refusal verdict. Returns whatever `fn` returns. */
71
72
  export function runWithRefusalScope(fn) {
@@ -96,6 +97,14 @@ export function refusalSeen() {
96
97
  return refusalScope.getStore()?.seen === true;
97
98
  }
98
99
 
100
+ // Prompt sites share this hook. Nested prompts count only the outer blocked
101
+ // interval, so a prompt helper can call another without inflating wait time.
102
+ export async function timeOperatorWait(fn) {
103
+ const active = invocationScope.getStore();
104
+ if (!active) return fn();
105
+ return active.wait(fn);
106
+ }
107
+
99
108
  /**
100
109
  * Run `fn()` and re-throw anything it throws as a tagged refusal, message
101
110
  * byte-identical. The contract it encodes: THE TAG IS APPLIED AT THE UP-FRONT
@@ -123,6 +132,10 @@ function isStringArray(value) {
123
132
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
124
133
  }
125
134
 
135
+ function isFindingCodes(value) {
136
+ return isStringArray(value) && value.length <= 5 && new Set(value).size === value.length;
137
+ }
138
+
126
139
  // Injectable so tests are deterministic. `now` is wall-clock (ISO source);
127
140
  // `monotonic` is a steadily-increasing millisecond counter for durations.
128
141
  const defaultClock = {
@@ -146,13 +159,22 @@ function defaultReadExitStatus() {
146
159
  export function createLifecycleRecorder(clock = defaultClock) {
147
160
  const stages = [];
148
161
  let repairLoopCount = 0;
162
+ let waitMs = 0;
163
+ let waitDepth = 0;
164
+ const activeStages = [];
165
+ const findingCodes = [];
149
166
  function stage(name) {
150
167
  const t0 = clock.monotonic();
168
+ const phase = { name: String(name), wait_ms: 0 };
169
+ activeStages.push(phase);
151
170
  let stopped = false;
152
171
  return function stop() {
153
172
  if (stopped) return;
154
173
  stopped = true;
155
- stages.push({ name: String(name), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
174
+ activeStages.splice(activeStages.indexOf(phase), 1);
175
+ // Wait accumulates unrounded and is rounded once here, so it never
176
+ // exceeds the stage's own rounded duration.
177
+ stages.push({ ...phase, wait_ms: Math.round(phase.wait_ms), duration_ms: Math.max(0, Math.round(clock.monotonic() - t0)) });
156
178
  };
157
179
  }
158
180
  // Convenience: time `fn` as a named sub-phase. Records the stage even if fn
@@ -168,11 +190,38 @@ export function createLifecycleRecorder(clock = defaultClock) {
168
190
  return {
169
191
  stage,
170
192
  time,
193
+ async wait(fn) {
194
+ if (waitDepth++) {
195
+ try { return await fn(); } finally { waitDepth -= 1; }
196
+ }
197
+ const t0 = clock.monotonic();
198
+ const phase = activeStages.at(-1);
199
+ try { return await fn(); }
200
+ finally {
201
+ const elapsed = Math.max(0, clock.monotonic() - t0);
202
+ waitMs += elapsed;
203
+ if (phase) phase.wait_ms += elapsed;
204
+ waitDepth -= 1;
205
+ }
206
+ },
207
+ recordFindingCodes(codes) {
208
+ // Codes attach to the phase active when they are recorded; outside any
209
+ // phase they stay invocation-level only.
210
+ const phase = activeStages.at(-1);
211
+ for (const code of Array.isArray(codes) ? codes : []) {
212
+ if (typeof code !== "string") continue;
213
+ if (!findingCodes.includes(code) && findingCodes.length < 5) findingCodes.push(code);
214
+ if (phase) {
215
+ phase.finding_codes ??= [];
216
+ if (!phase.finding_codes.includes(code) && phase.finding_codes.length < 5) phase.finding_codes.push(code);
217
+ }
218
+ }
219
+ },
171
220
  recordRepairLoop() {
172
221
  repairLoopCount += 1;
173
222
  },
174
223
  snapshot() {
175
- return { stages: stages.slice(), repair_loop_count: repairLoopCount };
224
+ return { stages: stages.slice(), repair_loop_count: repairLoopCount, wait_ms: Math.round(waitMs), finding_codes: findingCodes.slice() };
176
225
  },
177
226
  };
178
227
  }
@@ -183,7 +232,9 @@ export const NOOP_RECORDER = {
183
232
  stage: () => () => {},
184
233
  time: async (_name, fn) => fn(),
185
234
  recordRepairLoop: () => {},
186
- snapshot: () => ({ stages: [], repair_loop_count: 0 }),
235
+ wait: (fn) => fn(),
236
+ recordFindingCodes: () => {},
237
+ snapshot: () => ({ stages: [], repair_loop_count: 0, wait_ms: 0, finding_codes: [] }),
187
238
  };
188
239
 
189
240
  function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, completedAt, durationMs, recorder }) {
@@ -197,7 +248,10 @@ function buildLifecycle({ command, argvShape, runId, exitStatus, startedAt, comp
197
248
  started_at: startedAt,
198
249
  completed_at: completedAt,
199
250
  duration_ms: Number.isFinite(durationMs) ? Math.max(0, Math.round(durationMs)) : null,
200
- stages: recorded.stages,
251
+ wait_ms: recorded.wait_ms,
252
+ ...(Number.isInteger(exitStatus) && exitStatus !== 0 && recorded.finding_codes.length ? { finding_codes: recorded.finding_codes } : {}),
253
+ stages: recorded.stages.map(({ finding_codes, ...stage }) =>
254
+ Number.isInteger(exitStatus) && exitStatus !== 0 && finding_codes?.length ? { ...stage, finding_codes } : stage),
201
255
  repair_loop_count: recorded.repair_loop_count,
202
256
  };
203
257
  }
@@ -225,7 +279,7 @@ export async function withCommandLifecycle({
225
279
  let thrown = null;
226
280
  let exitStatus = 0;
227
281
  try {
228
- result = await fn(recorder);
282
+ result = await invocationScope.run(recorder, () => fn(recorder));
229
283
  // A clean return is exit 0 unless the command set process.exitCode.
230
284
  const raw = readExitStatus();
231
285
  exitStatus = Number.isInteger(raw) ? raw : 0;
@@ -278,6 +332,8 @@ export function validateLifecycle(entry) {
278
332
  if (entry.exit_status != null && !Number.isInteger(entry.exit_status)) add("lifecycle.exit_status", "exit_status must be an integer or null.");
279
333
  if (entry.run_id != null && typeof entry.run_id !== "string") add("lifecycle.run_id", "run_id must be a string or null.");
280
334
  if (entry.duration_ms != null && typeof entry.duration_ms !== "number") add("lifecycle.duration_ms", "duration_ms must be a number or null.");
335
+ if (entry.wait_ms != null && (!Number.isInteger(entry.wait_ms) || entry.wait_ms < 0)) add("lifecycle.wait_ms", "wait_ms must be a non-negative integer or null.");
336
+ if (entry.finding_codes !== undefined && (!isFindingCodes(entry.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add("lifecycle.finding_codes", "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
281
337
  if (entry.repair_loop_count != null && !Number.isInteger(entry.repair_loop_count)) add("lifecycle.repair_loop_count", "repair_loop_count must be an integer.");
282
338
  if (entry.stages != null) {
283
339
  if (!Array.isArray(entry.stages)) {
@@ -285,7 +341,11 @@ export function validateLifecycle(entry) {
285
341
  } else {
286
342
  entry.stages.forEach((stage, index) => {
287
343
  if (!stage || typeof stage !== "object" || Array.isArray(stage)) add(`lifecycle.stages[${index}]`, "each stage must be an object.");
288
- else if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
344
+ else {
345
+ if (!isNonEmptyString(stage.name)) add(`lifecycle.stages[${index}].name`, "stage name is required.");
346
+ if (stage.wait_ms != null && (!Number.isInteger(stage.wait_ms) || stage.wait_ms < 0)) add(`lifecycle.stages[${index}].wait_ms`, "wait_ms must be a non-negative integer or null.");
347
+ if (stage.finding_codes !== undefined && (!isFindingCodes(stage.finding_codes) || !Number.isInteger(entry.exit_status) || entry.exit_status === 0)) add(`lifecycle.stages[${index}].finding_codes`, "finding_codes must have at most five distinct strings and require a non-zero exit_status.");
348
+ }
289
349
  });
290
350
  }
291
351
  }
@@ -366,6 +426,8 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
366
426
  let latest = null;
367
427
  let durationSum = 0;
368
428
  let explicitRepairLoops = 0;
429
+ let needsInputCount = 0;
430
+ let failureCount = 0;
369
431
 
370
432
  // Only finite, parseable ISO timestamps participate in span timing; a junk
371
433
  // string (e.g. a hand-edited journal) is ignored rather than emitted as a
@@ -376,6 +438,11 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
376
438
  const command = typeof entry.command === "string" ? entry.command : "(unknown)";
377
439
  commandCounts.set(command, (commandCounts.get(command) || 0) + 1);
378
440
  const exitStatus = Number.isInteger(entry.exit_status) ? entry.exit_status : null;
441
+ if (exitStatus === 2) needsInputCount += 1;
442
+ else if (exitStatus !== null && exitStatus !== 0) failureCount += 1;
443
+ const findings = exitStatus !== null && exitStatus !== 0 && isStringArray(entry.finding_codes)
444
+ ? [...new Set(entry.finding_codes)].slice(0, 5) : [];
445
+ const findingFields = findings.length ? { finding_codes: findings } : {};
379
446
  // A command may have recorded its own repair loops via recordRepairLoop().
380
447
  if (Number.isInteger(entry.repair_loop_count)) explicitRepairLoops += entry.repair_loop_count;
381
448
 
@@ -384,12 +451,25 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
384
451
  name: `${command}:${typeof stage?.name === "string" ? stage.name : "stage"}`,
385
452
  duration_ms: typeof stage?.duration_ms === "number" ? stage.duration_ms : null,
386
453
  exit_status: exitStatus,
454
+ ...(Number.isInteger(stage?.wait_ms) && stage.wait_ms >= 0 ? { wait_ms: stage.wait_ms } : {}),
455
+ ...(exitStatus !== null && exitStatus !== 0 && isFindingCodes(stage?.finding_codes) && stage.finding_codes.length
456
+ ? { finding_codes: stage.finding_codes } : {}),
387
457
  }))
388
458
  : [{
389
459
  name: command,
390
460
  duration_ms: typeof entry.duration_ms === "number" ? entry.duration_ms : null,
391
461
  exit_status: exitStatus,
462
+ ...(Number.isInteger(entry.wait_ms) && entry.wait_ms >= 0 ? { wait_ms: entry.wait_ms } : {}),
463
+ ...findingFields,
392
464
  }];
465
+ if (subStages.length > 1 || (Array.isArray(entry.stages) && entry.stages.length)) {
466
+ const attributed = new Set(subStages.flatMap((stage) => stage.finding_codes ?? []));
467
+ const unknown = findings.filter((code) => !attributed.has(code));
468
+ if (unknown.length) {
469
+ const lastStage = subStages.at(-1);
470
+ lastStage.finding_codes = [...new Set([...(lastStage.finding_codes ?? []), ...unknown])].slice(0, 5);
471
+ }
472
+ }
393
473
  stages.push(...subStages);
394
474
 
395
475
  if (typeof entry.duration_ms === "number") durationSum += entry.duration_ms;
@@ -430,5 +510,7 @@ export function aggregateLifecycleForRun(journal, runId, { excludeCommands = []
430
510
  wall_clock_duration_ms: wallClockDurationMs == null ? null : Math.round(wallClockDurationMs),
431
511
  stages,
432
512
  repair_loop_count: repairLoopCount,
513
+ needs_input_count: needsInputCount,
514
+ failure_count: failureCount,
433
515
  };
434
516
  }
@@ -75,6 +75,12 @@ export function applyLocalPreviewToCheckpoint(gate, { packet, report, baseUrl =
75
75
 
76
76
  // The polish gate folds the checkpoint in, so it is carried forward only when
77
77
  // whatever it owns from the checkpoint was carried forward too.
78
+ export function polishCarriedForwardForLadder(report, gate) {
79
+ return gate?.status === CARRIED_FORWARD
80
+ && !report?.theme?.waiver
81
+ && !report?.stages?.polish?.evidence?.visual_review?.page_load;
82
+ }
83
+
78
84
  export function applyLocalPreviewToPolishGate(gate, { packet, checkpointGate = null, baseUrl = null } = {}) {
79
85
  if (gate?.status !== "blocked" || !isLocalPreview(packet, { baseUrl })) return gate;
80
86
  const checkpointCarried = checkpointGate?.status === CARRIED_FORWARD;
package/src/login.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createCredentialStore } from './credential-store.mjs';
2
+ import { timeOperatorWait } from './lifecycle.mjs';
2
3
 
3
4
  export const GATEWAY = 'https://mcp.nextcommerce.com';
4
5
  export const CLIENT_ID = 'campaigns-os-owned-store-pilot';
@@ -27,7 +28,7 @@ export function authenticationArguments(argv) {
27
28
  async function askStore() {
28
29
  const { createInterface } = await import('node:readline/promises');
29
30
  const prompt = createInterface({ input: process.stdin, output: process.stdout });
30
- try { return await prompt.question('Store subdomain (or network domain): '); }
31
+ try { return await timeOperatorWait(() => prompt.question('Store subdomain (or network domain): ')); }
31
32
  finally { prompt.close(); }
32
33
  }
33
34
  const bindingFor = store => ({ gateway: GATEWAY, client_id: CLIENT_ID, store });
@@ -1012,6 +1012,10 @@ function polishBrowserMissing(kind, error) {
1012
1012
  ].join(" "));
1013
1013
  }
1014
1014
 
1015
+ // `networkIdleTimeoutMs`, like the deadlines, can only shorten its bound: the
1016
+ // wait for network idle after each navigation (NETWORKIDLE_TIMEOUT_MS). The
1017
+ // CLI never passes it; a test whose page holds a transfer open passes a
1018
+ // shorter window rather than wait out the production one on every cell.
1015
1019
  export async function createPolishBrowserAdapter({
1016
1020
  headed = false,
1017
1021
  authCookie = null,
@@ -1019,6 +1023,7 @@ export async function createPolishBrowserAdapter({
1019
1023
  cellDeadlineMs,
1020
1024
  cleanupDeadlineMs,
1021
1025
  startupDeadlineMs,
1026
+ networkIdleTimeoutMs,
1022
1027
  } = {}) {
1023
1028
  const authCookies = parseAuthCookie(authCookie);
1024
1029
  const boundedCellDeadlineMs = boundedPolishDeadline(cellDeadlineMs, POLISH_BROWSER_CELL_DEADLINE_MS);
@@ -1030,6 +1035,7 @@ export async function createPolishBrowserAdapter({
1030
1035
  startupDeadlineMs,
1031
1036
  POLISH_BROWSER_STARTUP_DEADLINE_MS,
1032
1037
  );
1038
+ const networkIdleMs = boundedPolishDeadline(networkIdleTimeoutMs, NETWORKIDLE_TIMEOUT_MS);
1033
1039
  let browser;
1034
1040
  let startupTimedOut = false;
1035
1041
  const startupAbort = new AbortController();
@@ -1234,7 +1240,7 @@ export async function createPolishBrowserAdapter({
1234
1240
  const initialMediaElements = await awaitActive(collectMediaElements(page));
1235
1241
  let networkidle;
1236
1242
  try {
1237
- await awaitActive(page.waitForLoadState("networkidle", { timeout: NETWORKIDLE_TIMEOUT_MS }));
1243
+ await awaitActive(page.waitForLoadState("networkidle", { timeout: networkIdleMs }));
1238
1244
  networkidle = { status: "settled", duration_ms: durationSince(navigationStartedAt) };
1239
1245
  } catch (error) {
1240
1246
  if (!timeoutError(error)) throw error;
@@ -1327,7 +1333,7 @@ export async function createPolishBrowserAdapter({
1327
1333
  if (initialTree === BOUND_ENDED) return unmeasured(boundEnded());
1328
1334
  if (initialTree.error) return unmeasured("document_changed");
1329
1335
  const initialMainFrame = initialTree.value?.frameTree?.frame;
1330
- const idleTimeout = Math.max(1, Math.min(NETWORKIDLE_TIMEOUT_MS, Math.floor(addedLeft()), Math.floor(cellLeftMs())));
1336
+ const idleTimeout = Math.max(1, Math.min(networkIdleMs, Math.floor(addedLeft()), Math.floor(cellLeftMs())));
1331
1337
  const idle = await awaitActive(steps.step(() => page.waitForLoadState("networkidle", { timeout: idleTimeout })));
1332
1338
  if (idle === BOUND_ENDED) return unmeasured(boundEnded());
1333
1339
  if (idle.error && !timeoutError(idle.error)) throw idle.error;
@@ -669,6 +669,7 @@ async function runPolishCapture({
669
669
  captureCellDeadlineMs,
670
670
  adapterCloseDeadlineMs,
671
671
  probeClock = null,
672
+ mediaProbeRunBudgetMs,
672
673
  targetRepo = null,
673
674
  now = new Date(),
674
675
  } = {}) {
@@ -687,6 +688,10 @@ async function runPolishCapture({
687
688
  adapterCloseDeadlineMs,
688
689
  POLISH_CAPTURE_CLOSE_DEADLINE_MS,
689
690
  );
691
+ // The 1.3 image probe's run budget. Like the deadlines it can only shorten:
692
+ // the CLI never passes it, and a test that spends the budget passes a
693
+ // smaller one so that fewer slow cells spend it.
694
+ const probeRunBudgetMs = boundedPolishDeadline(mediaProbeRunBudgetMs, MEDIA_PROBE_LIMITS.runBudgetMs);
690
695
 
691
696
  let adapter = null;
692
697
  // Failure is tracked apart from its reason: a rejection may carry any value,
@@ -806,7 +811,7 @@ async function runPolishCapture({
806
811
  const abortController = new AbortController();
807
812
  const imageProbe = {
808
813
  ...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
809
- remainingMs: MEDIA_PROBE_LIMITS.runBudgetMs - probeSpentMs,
814
+ remainingMs: probeRunBudgetMs - probeSpentMs,
810
815
  cellBoundMs: MEDIA_PROBE_LIMITS.cellBoundMs,
811
816
  imageCap: MEDIA_PROBE_LIMITS.imageCap,
812
817
  };
@@ -433,8 +433,8 @@ export function readabilityProbe(toolkit, limits, closedRoots, generatedText) {
433
433
  // ancestor matches. checkout_label and checkout_hint are read separately.
434
434
  const ROLES = [
435
435
  ["add_to_cart", "[data-next-action=\"add-to-cart\"]"],
436
- ["upsell_accept", "[data-next-upsell-action=\"add\"]"],
437
- ["upsell_decline", "[data-next-upsell-action=\"skip\"]"],
436
+ ["upsell_accept", "[data-next-upsell-action=\"add\"], [data-next-upsell-action=\"accept\"]"],
437
+ ["upsell_decline", "[data-next-upsell-action=\"skip\"], [data-next-upsell-action=\"decline\"]"],
438
438
  ["submit_control", "button.submit-button[os-checkout-payment=\"combo\"], button[os-checkout-payment=\"combo\"], button[type=\"submit\"], input[type=\"submit\"], input[type=\"button\"]"],
439
439
  ["sdk_action", "[data-next-action]"],
440
440
  ["bundle_card", "[data-next-bundle-card], [data-next-selector-card], [data-next-package-id], [data-next-bundle-id]"],
@@ -96,7 +96,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
96
96
  }
97
97
  function lock(dir,fn,{budgetMs=1500}={}) {
98
98
  const lockPath=join(dir,'.allocation-lock');
99
- return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:()=>Object.assign(new Error('progress.lock_unavailable'),{lockPath})});
99
+ return withDirectoryLock(lockPath,fn,{budgetMs,unavailable:(error)=>Object.assign(new Error('progress.lock_unavailable'),{lockPath,code:error?.code,obstruction:error?.obstruction})});
100
100
  }
101
101
  export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
102
102
  mkdirSync(dir,{recursive:true,mode:0o700});
@@ -164,7 +164,8 @@ export async function observeProgress(args,continuation,{qaResult=null,packageVe
164
164
  if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
165
165
  return {...remit,snapshot_id:snapshot.snapshot_id,reused};
166
166
  } catch(error) {
167
- if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
167
+ if(error?.message==='progress.lock_unavailable'&&error.obstruction)warn(`[campaigns-os] Progress allocation lock path ${error.lockPath} is occupied by ${error.obstruction}; it is never removed automatically. Move it out of the way. Lifecycle result is unchanged.`);
168
+ else if(error?.message==='progress.lock_unavailable')warn(`[campaigns-os] Progress allocation lock occupied${error.lockPath?` at ${error.lockPath}`:''}; wait for the current writer. If it stays occupied (an abandoned, ownerless or interrupted lock), stop all campaigns-os writers for this target, then remove that lock directory (docs/progress-snapshots.md). Lifecycle result is unchanged.`);
168
169
  else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
169
170
  return {state:'failed',reason:'capture_unavailable'};
170
171
  }