@nextcommerce/campaigns-os 1.43.2 → 1.47.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 (80) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +798 -5103
  3. package/README.md +33 -12
  4. package/agents/claude/CLAUDE.md +1 -1
  5. package/agents/codex/AGENTS.md +1 -1
  6. package/agents/copilot/copilot-instructions.md +1 -1
  7. package/agents/cursor/campaigns-os.mdc +1 -1
  8. package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +2 -2
  9. package/campaign-spec/dist/rules/analytics-contract-shape.js +2 -2
  10. package/campaign-spec/dist/rules/store-profile-shape.d.ts +5 -1
  11. package/campaign-spec/dist/rules/store-profile-shape.js +8 -10
  12. package/campaign-spec/dist/types.d.ts +2 -2
  13. package/compatibility.json +1 -1
  14. package/contracts/archive/CHANGELOG.2026-09-30.md +5111 -0
  15. package/contracts/archive/release-ledger.2026-09-30.json +5068 -0
  16. package/contracts/commerce-surface-catalog.json +1204 -129
  17. package/contracts/effects.v1.json +1179 -116
  18. package/contracts/orientation-reason-codes.v1.json +7 -0
  19. package/contracts/release-ledger.json +2515 -5919
  20. package/contracts/supported-surface.json +7 -4
  21. package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
  22. package/contracts/template-slot-manifest.shared-content-core.v0.json +403 -0
  23. package/docs/brand-theme-bridge.md +81 -0
  24. package/docs/build-packet.md +180 -23
  25. package/docs/campaigns-os-build-flow.md +3 -3
  26. package/docs/design-source-package.md +73 -0
  27. package/docs/effects.md +50 -8
  28. package/docs/gateway-login.md +3 -0
  29. package/docs/local-setup.md +7 -4
  30. package/docs/orientation-contract-reference.md +42 -2
  31. package/docs/polish-evidence.md +74 -0
  32. package/docs/qa-and-test-orders.md +118 -14
  33. package/docs/release-ledger-authoring-guide.md +64 -4
  34. package/docs/runtime-readiness.md +1 -1
  35. package/docs/sdk-storage-compatibility.md +1 -1
  36. package/docs/skills-revision.md +10 -10
  37. package/docs/supported-surface.md +2 -2
  38. package/docs/versioning.md +4 -1
  39. package/package.json +1 -1
  40. package/schemas/campaigns-os-release-ledger.v1.schema.json +32 -2
  41. package/schemas/campaigns-os-tooling-orientation.v1.schema.json +1 -0
  42. package/skills/campaign-lifecycle-orientation/SKILL.md +16 -5
  43. package/skills/campaign-readback-classification/SKILL.md +3 -3
  44. package/skills/campaign-run-evidence/SKILL.md +7 -6
  45. package/skills/contribution-intake/SKILL.md +3 -3
  46. package/skills/next-campaigns-build/SKILL.md +7 -6
  47. package/skills/next-campaigns-os/SKILL.md +7 -7
  48. package/skills/next-campaigns-os/references/session-intake.md +9 -3
  49. package/skills/next-campaigns-os-setup/SKILL.md +5 -5
  50. package/skills/next-campaigns-polish/SKILL.md +28 -9
  51. package/skills/next-campaigns-qa/SKILL.md +7 -4
  52. package/skills.json +10 -10
  53. package/src/brand-theme.mjs +320 -20
  54. package/src/built-script-syntax.mjs +116 -15
  55. package/src/built-site-scope.mjs +16 -4
  56. package/src/cli.mjs +280 -46
  57. package/src/commercial-parity.mjs +48 -2
  58. package/src/deviation.mjs +13 -1
  59. package/src/diagnostic.mjs +6 -2
  60. package/src/doctor/checks.mjs +319 -81
  61. package/src/doctor/inspect.mjs +55 -13
  62. package/src/doctor/source-provenance.mjs +184 -0
  63. package/src/invocation.mjs +4 -0
  64. package/src/live-campaign-refs.mjs +466 -0
  65. package/src/login.mjs +2 -2
  66. package/src/page-kit-store-profile.mjs +69 -12
  67. package/src/page-kit-sync.mjs +31 -12
  68. package/src/progress-node.mjs +3 -1
  69. package/src/qa-analytics-parity.mjs +37 -2
  70. package/src/qa-binding-evidence.mjs +4 -2
  71. package/src/qa-browser.mjs +612 -40
  72. package/src/qa-commercial-parity.mjs +48 -5
  73. package/src/qa-node.mjs +122 -7
  74. package/src/qa-test-order-topology.mjs +148 -0
  75. package/src/sdk-markup.mjs +32 -7
  76. package/src/sdk-storage-compatibility.mjs +3 -2
  77. package/src/source-html-intake.mjs +116 -0
  78. package/src/stage-record.mjs +551 -0
  79. package/src/tooling-setup.mjs +9 -0
  80. package/src/upsell-selector-scope.mjs +112 -2
package/src/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  chmodSync,
8
8
  cpSync,
9
9
  existsSync,
10
+ lstatSync,
10
11
  mkdirSync,
11
12
  readdirSync,
12
13
  readFileSync,
@@ -106,7 +107,10 @@ import {
106
107
  import { ensureRuntimeStateIgnored } from "./runtime-state-ignore.mjs";
107
108
  import {
108
109
  createSourceHtmlIntake,
110
+ HOST_STRIPPED_CODE,
111
+ parseHostPrefixedRoute,
109
112
  publicRouteForPage,
113
+ stripHostPrefixedRoutes,
110
114
  } from "./source-html-intake.mjs";
111
115
  import {
112
116
  SOURCE_HTML_MANIFEST_REL_PATH,
@@ -122,7 +126,7 @@ import {
122
126
  createStandardizationReport,
123
127
  formatStandardizationReportMarkdown,
124
128
  } from "./standardization-report.mjs";
125
- import { singleLineDetail, singleLineField } from "./text-safety.mjs";
129
+ import { singleLineDetail, singleLineField, singleLineFragment } from "./text-safety.mjs";
126
130
  import { derivePackagePin, LOCAL_INVOCATION_PREFIX, localInstallStatus, resolveInvocation } from "./install-mode.mjs";
127
131
  import {
128
132
  campaignRouteRoot,
@@ -195,6 +199,7 @@ import {
195
199
  } from "./polish-node.mjs";
196
200
  import { HIDDEN_EAGER_MEDIA_SCOPE, POLISH_CAPTURE_PROBLEM_CODES } from "./polish-page-load.mjs";
197
201
  import { POLISH_BEACON_RESOURCE_TYPES, captureOrigin, redactCaptureUrl } from "./polish-capture.mjs";
202
+ import { SOURCE_PROVENANCE_EXPORTER_CLAIM_CODE, SOURCE_PROVENANCE_SCOPE } from "./doctor/source-provenance.mjs";
198
203
  import {
199
204
  appendCheckpointWaiver,
200
205
  createCheckpointRegistry,
@@ -253,7 +258,7 @@ import {
253
258
  addIssue,
254
259
  } from "./cli-helpers.mjs";
255
260
  import { resolveCampaignsApiKeySource, describeCampaignKeyRejection } from "./campaigns-api-key.mjs";
256
- import { doctorCommand, doctorBuiltOutput, doctorPacket } from "./doctor/inspect.mjs";
261
+ import { doctorCommand, doctorBuiltOutput, doctorPacket, readDoctorLiveCampaign } from "./doctor/inspect.mjs";
257
262
  import {
258
263
  PACKET_SCHEMA,
259
264
  CONTEXT_SCHEMA,
@@ -300,7 +305,7 @@ Usage:
300
305
  [--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
301
306
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
302
307
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
303
- campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--json] # inspection by default; --doctor-out requires --write; --no-write wins
308
+ 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
304
309
  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
305
310
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
306
311
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
@@ -308,11 +313,14 @@ Usage:
308
313
  campaigns-os theme inspect --packet <campaign-runtime.build.json> [--context <json>] [--theme-policy <inspect_only|auto|off>] [--json]
309
314
  campaigns-os theme generate --packet <campaign-runtime.build.json> [--context <json>] [--out-dir <dir>] [--force] [--json]
310
315
  campaigns-os theme waive --packet <campaign-runtime.build.json> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--report <json>] [--dry-run] [--json] # record an explicit theme-gate waiver on the assembly report; placeholders such as "operator" are refused. --dry-run validates the same way and prints the waiver it would write, without touching the report
311
- campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope. --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
316
+ campaigns-os checkpoint waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"] [--report <json>] [--dry-run] [--json] # one bound is required; registered gates: page_kit.store_profile, page_kit.sdk_version, polish.hidden_eager_media, built_output.upsell_selector_scope, source_html.producer_provenance (per page: --page <page_id> is required, for a Figma-typed page whose approved source is hand-written HTML). --dry-run runs every check (named human, bounds, registered and waivable gate) and prints the waiver it would write, without touching the report
312
317
  campaigns-os page-kit sync --packet <campaign-runtime.build.json> [--dry-run] [--json] # write the CampaignSpec's Store Profile fields (campaign.store_*) and SDK pin (global_config.sdk_version, runtime.sdk_version alias) into the target's _data/campaigns.json entry for the packet's route, printing a field-by-field diff; the recovery for a doctor blocked on page_kit.store_profile / page_kit.sdk_version after a fresh scaffold. Writes only those ten fields, only from usable spec values (a bad pin, a non-http URL, a non-tel: phone URI or the demo value itself is reported as not synced, status PARTIAL); exit 2 when the entry or the spec is missing, or the spec identifies another campaign.
313
318
  campaigns-os spec derive --packet <campaign-runtime.build.json> [--dry-run] [--json] [--report <json>] [--from-store <subdomain> [--store-token-source env:<VAR>]] [--write-map] [--proxy-base <url>] # write the fields the target repo already states into the packet's local CampaignSpec (spec.local_path): the SDK pin from _data/campaigns.json[<route>].sdk_version (global_config.sdk_version, and the runtime.sdk_version alias when declared), each page's page_url from the page tree under src/<route>/ (filename or permalink), and the analytics ids the entry carries (gtm_id -> analytics.providers.gtm.containerId, fb_pixel_id -> analytics.providers.facebook.pixelId); prints a field-by-field before -> after diff and writes nothing else. Repo-derived fields only and no network by default; --from-store <subdomain> (the <store> of <store>.29next.store) also reads through campaigns-os login gateway credentials (--store-token-source env:<VAR> explicitly selects the warned break-glass Admin path; a token never goes on the command line) and writes the nine campaign.store_* Store Profile fields: store_name and store_url (primary domain) and store_phone/store_phone_tel from GET /store/, and store_terms/privacy/contact/returns/shipping as https://<primary domain>/<slug>/ from the one storefront page (GET /pages/) whose slug or title names each policy; an empty store field, no page or several never empties the spec's value. A field the repo or store cannot state (a scaffold's seeded pin, an unbound page, an empty or malformed id, an active page_kit.sdk_version waiver, an empty store field, an unbound policy page) is reported as not derived, status PARTIAL; exit 2 when the packet, the spec or the target entry is missing, the spec identifies another campaign, or the store cannot be read (credential missing, 401/403, no such store, unreachable). --write-map also records the derived pin into the saved Map's Build hints (Campaign Cart SDK version) through the proxy Worker (PUT /api/maps/<spec.map_id> under X-Campaign-Key, the packet's Campaigns API key, with the Map's spec_hash as the X-Spec-Hash precondition): written when the Map declares no pin or one behind the repo, unchanged when equal, refused (warning, exit 0) when the Map pin is ahead or cannot be ordered, failed (error, exit 2) when the key is missing or mismatched, the Map is gone, was saved in between, or the proxy refuses the body; the write is recorded on the Assembly Report evidence[] and in the result's map object. --proxy-base overrides the canonical proxy (https, or a loopback host over http); --dry-run reads the Map and reports would_write without a PUT.
314
319
  campaigns-os page-kit parity --packet <campaign-runtime.build.json> [--report <json>] [--json] # local proof mode (deploy.target local-serve): render the current source in development and production through the target's page-kit into temp dirs, assert the served _site/ is the current development render and that production differs from it only in environment-gated output (same page set, same route slugs, same Campaign Cart pin and next-api-key); records stages.assembly.evidence.local_proof.production_parity, which doctor reads as local_proof.production_parity. Exit 2 on a non-gated difference.
315
320
  campaigns-os polish capture --packet <campaign-runtime.build.json> --base-url <url> [--report <json>] [--headed] [--auth-cookie <cookie>] [--json]
321
+ 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
322
+ campaigns-os record build --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json] # after page-kit build: stages.assembly completed with build_fingerprint = doctor's derived.build_output_fingerprint.value (and the Design Source Package material fingerprint when the report has one); stages.polish becomes required unless its evidence is bound to this exact output
323
+ 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
316
324
  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.
317
325
  campaigns-os readback --example [--json] # project the bundled synthetic sample; freshness is not computable for it by design
318
326
  campaigns-os validate-assembly-report --report <json> [--json]
@@ -330,7 +338,7 @@ Usage:
330
338
  campaigns-os next deploy --packet <json> --report <json> [--json]
331
339
  campaigns-os next qa --packet <json> --report <json> [--json]
332
340
  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
333
- 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] [--output-dir <dir>] [--json]
341
+ 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]
334
342
  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
335
343
  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
336
344
  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
@@ -641,6 +649,7 @@ function persistDeviationIfDetected(args, command, lifecycle, ambient) {
641
649
  const entry = detectDeviation({
642
650
  lastRecommendation: ambient.session.last_recommendation,
643
651
  command,
652
+ subcommand: optionalString(args._?.[1]) || null,
644
653
  argvShape: lifecycle?.argv_shape || [],
645
654
  runId: ambient.session.run_id || null,
646
655
  deviationReason: optionalString(args["deviation-reason"]) || null,
@@ -648,8 +657,11 @@ function persistDeviationIfDetected(args, command, lifecycle, ambient) {
648
657
  if (!entry) return;
649
658
  const journalPath = join(ambient.dir, DEVIATION_JOURNAL_REL_PATH);
650
659
  appendDeviation(journalPath, entry);
651
- process.stderr.write(
652
- `[campaigns-os] deviation recorded: \`${command}\` ran while next recommended stage "${entry.recommended_stage}" (expected: ${entry.recommended_commands.join(", ") || "none"}). Declare intent with --deviation-reason, or follow \`campaigns-os next\`.\n`,
660
+ // A declared detour gets one confirming line; only an undeclared one is
661
+ // told how to declare intent.
662
+ process.stderr.write(entry.deviation_reason
663
+ ? `[campaigns-os] deviation recorded with reason: \`${command}\` ran while next recommended stage "${entry.recommended_stage}"; reason: "${singleLineField(entry.deviation_reason)}".\n`
664
+ : `[campaigns-os] deviation recorded: \`${command}\` ran while next recommended stage "${entry.recommended_stage}" (expected: ${entry.recommended_commands.join(", ") || "none"}). Declare intent with --deviation-reason, or follow \`campaigns-os next\`.\n`,
653
665
  );
654
666
  } catch {
655
667
  // telemetry never blocks a command
@@ -980,7 +992,10 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
980
992
  }
981
993
 
982
994
  if (command === "doctor") {
983
- const result = doctorCommand(args);
995
+ // The live campaign ref check's one read (#533), made before the
996
+ // synchronous inspection; see readDoctorLiveCampaign.
997
+ const liveCampaign = await readDoctorLiveCampaign(args);
998
+ const result = doctorCommand(args, { liveCampaign });
984
999
  writeResult(result, args, result.ok ? 0 : 2);
985
1000
  printDoctorTinyPrompt(result, args);
986
1001
  return;
@@ -1050,6 +1065,12 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1050
1065
  return;
1051
1066
  }
1052
1067
 
1068
+ if (command === "record") {
1069
+ const { recordStageCommand } = await import("./stage-record.mjs");
1070
+ writeResult(recordStageCommand(args), args, 0);
1071
+ return;
1072
+ }
1073
+
1053
1074
  if (command === "readback") {
1054
1075
  // Read-only projection over a target's already-emitted artifacts. It owns
1055
1076
  // its own exit codes (0 for any projection it could form, 2 for a request
@@ -1267,10 +1288,14 @@ async function resolveSpecPath(args, opts = {}) {
1267
1288
  }
1268
1289
  return { specPath: cachePath, source: "cache", mapId, proxyBase };
1269
1290
  }
1291
+ // Refuse a symlinked cache before fetching, and again at the write, which
1292
+ // may run later under the prepare-build lock.
1293
+ assertFetchedSpecCacheWritable(targetRepo, cachePath);
1270
1294
  const spec = await fetchSpecByMapId(mapId, { proxyBase, fetchImpl: opts.fetchImpl });
1271
1295
  const publishSpec = () => {
1296
+ assertFetchedSpecCacheWritable(targetRepo, cachePath);
1272
1297
  mkdirSync(cacheDir, { recursive: true });
1273
- writeFileSync(cachePath, `${JSON.stringify(spec, null, 2)}\n`);
1298
+ replaceFetchedSpec(cachePath, `${JSON.stringify(spec, null, 2)}\n`);
1274
1299
  };
1275
1300
  // deferCacheWrite hands the cache write back to the caller, which makes it
1276
1301
  // under the prepare-build lock.
@@ -1288,6 +1313,45 @@ async function resolveSpecPath(args, opts = {}) {
1288
1313
  );
1289
1314
  }
1290
1315
 
1316
+ // The fetch cache is written only inside a real
1317
+ // <target>/.campaign-runtime/fetched-specs/ directory. Throws when
1318
+ // .campaign-runtime/, fetched-specs/ or the cache entry exists as a symlink or
1319
+ // as the wrong kind of file, so a cache write cannot follow a link out of the
1320
+ // target. Missing entries are fine; the caller creates them.
1321
+ function assertFetchedSpecCacheWritable(targetRepo, cachePath) {
1322
+ const checks = [
1323
+ [join(targetRepo, ".campaign-runtime"), "directory"],
1324
+ [dirname(cachePath), "directory"],
1325
+ [cachePath, "regular file"],
1326
+ ];
1327
+ for (const [path, kind] of checks) {
1328
+ let stat;
1329
+ try {
1330
+ stat = lstatSync(path);
1331
+ } catch (error) {
1332
+ if (error.code === "ENOENT") continue;
1333
+ throw error;
1334
+ }
1335
+ if (kind === "directory" ? stat.isDirectory() : stat.isFile()) continue;
1336
+ throw new Error(
1337
+ `Refusing to write the fetched CampaignSpec: ${path} is ${stat.isSymbolicLink() ? "a symlink" : `not a ${kind}`}. `
1338
+ + "The fetch cache is written only inside a real <target>/.campaign-runtime/fetched-specs/ directory; remove the link and run again.",
1339
+ );
1340
+ }
1341
+ }
1342
+
1343
+ // Writes `bytes` to a temp file beside the cache entry and renames it over the
1344
+ // entry, so another name for the old file (a hard link) is never written.
1345
+ function replaceFetchedSpec(cachePath, bytes) {
1346
+ const tmpPath = join(dirname(cachePath), `.${basename(cachePath)}.${randomUUID()}.tmp`);
1347
+ try {
1348
+ writeFileSync(tmpPath, bytes, { flag: "wx" });
1349
+ renameSync(tmpPath, cachePath);
1350
+ } finally {
1351
+ rmSync(tmpPath, { force: true });
1352
+ }
1353
+ }
1354
+
1291
1355
  function relFromFile(filePath, targetPath) {
1292
1356
  const fromDir = dirname(resolve(filePath));
1293
1357
  const rel = relative(fromDir, resolve(targetPath));
@@ -1517,7 +1581,22 @@ function prepareBuildUnderLock({
1517
1581
  publication,
1518
1582
  }) {
1519
1583
  const { packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath } = publication.paths;
1520
- const spec = readJson(specPath);
1584
+ // #531: a host-prefixed route ("shop.example.com/route/upsell/") in a Map
1585
+ // fetched by this run is reduced to its rooted path before anything reads
1586
+ // the spec. The fresh fetch is intake's own file under
1587
+ // .campaign-runtime/fetched-specs/, so the rooted spec replaces it once the
1588
+ // Assembly Report recording each change (with the value as fetched) is
1589
+ // published, and doctor, polish and QA read what intake read. A local --spec
1590
+ // file and a copy reused with --cached-spec are never rewritten: they are
1591
+ // read as they are, and doctor blocks on routing_meta.host_prefixed.
1592
+ const specOnDisk = readJson(specPath);
1593
+ const hostStripped = stripHostPrefixedRoutes(specOnDisk);
1594
+ const specSource = options.specInput?.source || "local";
1595
+ const strippedSpecBytes = specSource === "remote" && hostStripped.evidence.length
1596
+ ? `${JSON.stringify(hostStripped.spec, null, 2)}\n`
1597
+ : null;
1598
+ const spec = strippedSpecBytes == null ? specOnDisk : hostStripped.spec;
1599
+ const specFileHash = strippedSpecBytes == null ? sha256File(specPath) : createHash("sha256").update(strippedSpecBytes).digest("hex");
1521
1600
  const { mapId, publicRouteSlug, localSpecId } = campaignIdentity(spec, args);
1522
1601
  if (!resolveCampaignIdentity({ map_id: mapId, local_spec_id: localSpecId })) {
1523
1602
  throw new Error("CampaignSpec requires exactly one identity: a saved spec_identity.map_id, or an agent-authored spec_identity.local_spec_id (1–64 letters, digits, underscores or hyphens). Keep the local ID stable across revisions; do not invent a Map ID.");
@@ -1809,7 +1888,7 @@ function prepareBuildUnderLock({
1809
1888
  },
1810
1889
  qa: {
1811
1890
  proof_policy: proofPolicy,
1812
- 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` for checkout, first-offer accept/decline, and a deduplicated shortest real receipt path when needed (at most four orders). Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag: depth is the only control.",
1891
+ test_order_policy_notes: "Test Orders use global test cards that bypass the gateway and create no transactions. Run them any time with `qa run --test-order common`: when every actual terminal path in the selected checkout topology fits under the flood cap, common runs them all (effective depth `full`, reason `under_cap`); above the cap it runs checkout, first-offer accept/decline and a deduplicated shortest real receipt path, then adds the shortest path that clicks the decline on each offer or downsell page no planned path declines yet, up to the cap, and names any page left out. The `browser-test-order:upsell-action-coverage` verdict row warns naming each offer page whose decline no executed order clicked. Use `--test-order full` for every actual terminal path in the selected checkout topology; cycles, missing routes, and reachable nonterminals block exhaustive proof before browser launch. Use `--test-order tiers` (or `tiers:common` / `tiers:full`) to drive one strict-selection order per selector tier the CampaignSpec declares on the checkout page, crossed with those path shapes; order-bump rows marked `is_upsell` are add-ons, not tiers, so a three-tier checkout with one bump plans 3 tiers, and `--select-package <ref[:qty],...>` narrows a tiers run to the listed tiers. The default accidental-flood cap is 6, and an overflow names the exact explicit `--max-test-orders` raise and lists the planned paths (up to 40 ids, the remainder counted). That cap bounds planned paths; `--max-order-creations` bounds actual order creations, defaults to the planned path count, and is reserved before each submit. Localhost on any port is a globally allowed Development domain; non-localhost preview/production origins still need SDK origin allowlist confirmation. There is no permission flag: depth is the only control.",
1813
1892
  },
1814
1893
  notes: "Generated by campaigns-os prepare-build. Replace demo refs from CampaignSpec/API before launch.",
1815
1894
  };
@@ -1826,7 +1905,11 @@ function prepareBuildUnderLock({
1826
1905
  : null,
1827
1906
  map_id: specInput?.mapId || null,
1828
1907
  proxy_base: specInput?.proxyBase || null,
1829
- saved_map_revision: specInput?.savedMapRevision || null,
1908
+ // The Map revision stays the one fetched; the local material hash is the
1909
+ // spec as written after host stripping, so the two still read as aligned.
1910
+ saved_map_revision: specInput?.savedMapRevision
1911
+ ? (strippedSpecBytes == null ? specInput.savedMapRevision : { ...specInput.savedMapRevision, local_spec_material_hash: specMaterialHash(spec) })
1912
+ : null,
1830
1913
  source_root: portable(sourceRoot),
1831
1914
  target_repo: portable(targetRepo),
1832
1915
  template_family: explicitTemplateFamily || null,
@@ -1847,7 +1930,7 @@ function prepareBuildUnderLock({
1847
1930
  design_source_package: designSourcePackage.referenceFor(contextPath),
1848
1931
  spec: {
1849
1932
  path: portable(specPath),
1850
- hash: sha256File(specPath),
1933
+ hash: specFileHash,
1851
1934
  material_hash: specMaterialHash(spec),
1852
1935
  active_pages: activePages.map((page) => ({
1853
1936
  id: page.id,
@@ -1955,9 +2038,45 @@ function prepareBuildUnderLock({
1955
2038
  declaredScopeSkips,
1956
2039
  buildScopeReasonsInvalid,
1957
2040
  templateSelection,
2041
+ evidence: strippedSpecBytes == null ? [] : hostStripped.evidence,
1958
2042
  }));
1959
2043
 
2044
+ // Values a spec left as it is still holds that doctor blocks on. An
2045
+ // absolute http(s) page_url is not among them: projection takes its path.
2046
+ const unstrippedHostRoutes = strippedSpecBytes == null
2047
+ ? hostStripped.evidence.filter((entry) => parseHostPrefixedRoute(entry.from.trim(), { keepAbsolute: true }))
2048
+ : [];
2049
+ // A cache that cannot be rewritten stops the run before the report that
2050
+ // records the stripped hosts is published.
2051
+ if (strippedSpecBytes != null) assertFetchedSpecCacheWritable(targetRepo, specPath);
1960
2052
  publication.publish({ packet, brief: buildBrief.artifact, context, report });
2053
+ if (unstrippedHostRoutes.length) {
2054
+ const changes = unstrippedHostRoutes.map((entry) => `${JSON.stringify(entry.from)} -> ${JSON.stringify(entry.to)}`).join(", ");
2055
+ console.warn(specSource === "cache"
2056
+ ? `[campaigns-os prepare-build] the cached spec copy ${specPath} holds ${unstrippedHostRoutes.length} host-prefixed route value(s): ${changes}; `
2057
+ + "--cached-spec reuses the copy as it is, so it was not changed, and doctor blocks with routing_meta.host_prefixed; re-run without --cached-spec so the Map is fetched and normalised."
2058
+ : `[campaigns-os prepare-build] the spec file ${specPath} holds ${unstrippedHostRoutes.length} host-prefixed route value(s) and must be edited to the rooted form: ${changes}; `
2059
+ + "it was not changed, and doctor blocks with routing_meta.host_prefixed until it is edited.");
2060
+ }
2061
+ // Only now that the report holding the evidence is out: a failed publish
2062
+ // leaves the copy exactly as fetched, so the rooted copy never exists
2063
+ // without the record of what it replaced.
2064
+ if (strippedSpecBytes != null) {
2065
+ try {
2066
+ replaceFetchedSpec(specPath, strippedSpecBytes);
2067
+ } catch (error) {
2068
+ console.warn(
2069
+ `[campaigns-os prepare-build] the assembly report ${reportPath} records the stripped host(s) as ${HOST_STRIPPED_CODE}, `
2070
+ + `but the cached spec ${specPath} was not rewritten and still holds the values as fetched: ${error.message}`,
2071
+ );
2072
+ throw error;
2073
+ }
2074
+ const changes = hostStripped.evidence.map((entry) => `${JSON.stringify(entry.from)} -> ${JSON.stringify(entry.to)}`).join(", ");
2075
+ console.warn(
2076
+ `[campaigns-os prepare-build] removed the host from ${hostStripped.evidence.length} CampaignSpec route value(s) and rewrote the fetched copy ${specPath}: ${changes}; `
2077
+ + `recorded as ${HOST_STRIPPED_CODE} on the assembly report evidence[].`,
2078
+ );
2079
+ }
1961
2080
 
1962
2081
  let doctor = null;
1963
2082
  // Housekeeping for the target's git history: the machine-local half of
@@ -2087,6 +2206,7 @@ function createAssemblyReport({
2087
2206
  declaredScopeSkips = [],
2088
2207
  buildScopeReasonsInvalid = false,
2089
2208
  templateSelection = null,
2209
+ evidence = [],
2090
2210
  }) {
2091
2211
  const scaffoldRequired = context.scaffold.required;
2092
2212
  const portable = (path) => relFromDir(targetRepo, path);
@@ -2104,7 +2224,7 @@ function createAssemblyReport({
2104
2224
  public_route_slug: packet.campaign.public_route_slug,
2105
2225
  campaign_directory: packet.campaign.campaign_directory,
2106
2226
  live_url_path: packet.campaign.live_url_path,
2107
- spec_hash: sha256File(specPath),
2227
+ spec_hash: context.spec.hash,
2108
2228
  spec_material_hash: context.spec.material_hash,
2109
2229
  },
2110
2230
  inputs: {
@@ -2145,7 +2265,7 @@ function createAssemblyReport({
2145
2265
  adapter_decisions: cloneJson(context.adapter_decisions || createAdapterDecisions()),
2146
2266
  proof_policy: cloneJson(packet.qa?.proof_policy || createProofPolicy()),
2147
2267
  theme: assemblyThemeFromContext(context.theme),
2148
- evidence: [],
2268
+ evidence: cloneJson(evidence),
2149
2269
  blockers,
2150
2270
  warnings: [
2151
2271
  ...(templateSelection?.overridden
@@ -2642,8 +2762,26 @@ const CHECKPOINT_EVALUATORS = createCheckpointRegistry([
2642
2762
  id: HIDDEN_EAGER_MEDIA_SCOPE,
2643
2763
  evaluate: ({ packet, report }) => evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
2644
2764
  },
2765
+ {
2766
+ // Per page (#534): one gate per Figma-typed CampaignSpec page, selected
2767
+ // with --page. An id that names no such page is refused here rather than
2768
+ // reported as missing evidence, so the operator learns which ids exist.
2769
+ id: SOURCE_PROVENANCE_SCOPE,
2770
+ evaluate: ({ doctor, pageId }) => {
2771
+ const gates = Array.isArray(doctor?.derived?.checkpoint_gates)
2772
+ ? doctor.derived.checkpoint_gates.filter((gate) => gate?.id === SOURCE_PROVENANCE_SCOPE)
2773
+ : [];
2774
+ const gate = gates.find((candidate) => candidate?.subject?.page_id === pageId);
2775
+ if (gate) return gate;
2776
+ const known = gates.map((candidate) => candidate.subject.page_id);
2777
+ throw new Error(`Page "${pageId}" has no ${SOURCE_PROVENANCE_SCOPE} checkpoint: it is not an active CampaignSpec page with a Figma design_source, or doctor found no valid source-html manifest to check. Pages with this checkpoint now: ${known.length ? known.join(", ") : "(none)"}.`);
2778
+ },
2779
+ },
2645
2780
  ]);
2646
2781
 
2782
+ // Registered gates that are waived one CampaignSpec page at a time.
2783
+ const PER_PAGE_CHECKPOINT_GATES = new Set([SOURCE_PROVENANCE_SCOPE]);
2784
+
2647
2785
  // A waive refusal under --json is a JSON envelope on stdout, exit 1 — the same
2648
2786
  // channel the success shape uses — so a caller parsing the output learns why
2649
2787
  // (and, for a checkpoint, which gates exist) without reading free text on
@@ -2668,23 +2806,61 @@ function waiveOrRefuse(args, run, { gate = null, registeredGates = [] } = {}) {
2668
2806
  function checkpointCommand(args) {
2669
2807
  const subcommand = args._[1] || "help";
2670
2808
  if (subcommand !== "waive") {
2671
- throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
2809
+ throw refused(`Unknown checkpoint subcommand. Use: ${cmd("checkpoint")} waive --packet <campaign-runtime.build.json> --gate <checkpoint-id> [--page <page_id>] --reason "<why>" --waived-by "<named human>" [--expires-at <ISO>] [--review-condition "<trigger>"]. Registered gates: ${Object.keys(CHECKPOINT_EVALUATORS).join(", ")}.`);
2672
2810
  }
2673
2811
  return checkpointWaive(args);
2674
2812
  }
2675
2813
 
2814
+ // Every value-taking flag of `checkpoint waive`. Given bare (the parser reads
2815
+ // it as boolean true) or with an empty value, each is refused by name rather
2816
+ // than coerced to the string "true" or "" — a bare --review-condition must not
2817
+ // supply a bound, nor a bare --report a report path.
2818
+ const CHECKPOINT_WAIVE_VALUE_FLAGS = Object.freeze({
2819
+ packet: "<campaign-runtime.build.json>",
2820
+ gate: "<checkpoint-id>",
2821
+ page: "<page_id>",
2822
+ reason: "\"<why>\"",
2823
+ "waived-by": "\"<named human>\"",
2824
+ "expires-at": "<ISO>",
2825
+ "review-condition": "\"<trigger>\"",
2826
+ report: "<json>",
2827
+ });
2828
+
2829
+ function refuseBareCheckpointWaiveFlags(args) {
2830
+ for (const [key, placeholder] of Object.entries(CHECKPOINT_WAIVE_VALUE_FLAGS)) {
2831
+ if (!Object.hasOwn(args, key) || args[key] == null) continue;
2832
+ if (!isNonEmptyString(args[key])) {
2833
+ throw refused(`--${key} needs a value: pass --${key} ${placeholder}.`);
2834
+ }
2835
+ }
2836
+ }
2837
+
2676
2838
  export function checkpointWaive(args) {
2839
+ refuseBareCheckpointWaiveFlags(args);
2677
2840
  const packetPath = resolve(requireArg(args, "packet"));
2678
2841
  const dryRun = isDryRun(args);
2679
2842
  const gateId = requireArg(args, "gate").trim();
2843
+ const pageId = args.page == null ? null : args.page.trim();
2844
+ // One spelling for the page scope: --page. The <gate>:<page_id> form is
2845
+ // refused by name rather than falling through to "unknown gate".
2846
+ const colon = gateId.indexOf(":");
2847
+ if (colon > 0 && PER_PAGE_CHECKPOINT_GATES.has(gateId.slice(0, colon))) {
2848
+ throw new Error(`Checkpoint gate "${gateId}" uses the <gate>:<page_id> form, which is not accepted; pass --gate ${gateId.slice(0, colon)} --page ${gateId.slice(colon + 1) || "<page_id>"} instead.`);
2849
+ }
2850
+ if (PER_PAGE_CHECKPOINT_GATES.has(gateId) && !pageId) {
2851
+ throw new Error(`Checkpoint gate "${gateId}" is waived per page; pass --page <page_id> naming the CampaignSpec page.`);
2852
+ }
2853
+ if (pageId != null && Object.hasOwn(CHECKPOINT_EVALUATORS, gateId) && !PER_PAGE_CHECKPOINT_GATES.has(gateId)) {
2854
+ throw new Error(`--page applies only to per-page checkpoint gates (${[...PER_PAGE_CHECKPOINT_GATES].join(", ")}); "${gateId}" is waived for the whole campaign.`);
2855
+ }
2680
2856
  const reason = requireArg(args, "reason");
2681
2857
  const waivedBy = requireArg(args, "waived-by");
2682
- const expiresAt = args["expires-at"] == null ? null : String(args["expires-at"]);
2683
- const reviewCondition = args["review-condition"] == null ? null : String(args["review-condition"]);
2858
+ const expiresAt = args["expires-at"] ?? null;
2859
+ const reviewCondition = args["review-condition"] ?? null;
2684
2860
  const packet = readJson(packetPath);
2685
2861
  const workspace = resolveCampaignWorkspace(packetPath, {
2686
2862
  packet,
2687
- reportPath: args.report ? resolve(String(args.report)) : undefined,
2863
+ reportPath: args.report == null ? undefined : resolve(args.report),
2688
2864
  followContextPointer: false,
2689
2865
  });
2690
2866
  const { reportPath } = workspace;
@@ -2693,12 +2869,15 @@ export function checkpointWaive(args) {
2693
2869
  const doctor = doctorPacket(packetPath, { reportPath });
2694
2870
  let waiver = null;
2695
2871
  const recordWaiver = (report) => {
2696
- const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report });
2872
+ const gate = evaluateCheckpointRegistry(CHECKPOINT_EVALUATORS, gateId, { doctor, packet, report, pageId });
2697
2873
  if (!gate) throw new Error(`Checkpoint gate "${gateId}" has no current evidence; repair the packet/spec/target and re-run doctor.`);
2698
2874
  if (gate.status !== "blocked") {
2699
2875
  throw new Error(`Checkpoint gate "${gateId}" is not blocked (status=${gate.status}); no waiver was recorded.`);
2700
2876
  }
2701
2877
  if (gate.waivable !== true) {
2878
+ if (gate.code === SOURCE_PROVENANCE_EXPORTER_CLAIM_CODE) {
2879
+ throw new Error(`Checkpoint gate "${gateId}" cannot be waived for page "${pageId}": the source-html manifest's generator claims figma-sections-export, so its missing provenance is the export's own defect. Re-run figma-sections-export, or, when the approved source is hand-written HTML, set the manifest's generator to name the real producer and waive again.`);
2880
+ }
2702
2881
  const residueFields = gateId === PAGE_KIT_STORE_PROFILE_SCOPE ? storeProfileDemoResidueFields(gate) : [];
2703
2882
  if (residueFields.length) {
2704
2883
  throw new Error(`Checkpoint gate "${gateId}" cannot be waived: ${residueFields.join(", ")} still carr${residueFields.length === 1 ? "ies" : "y"} starter demo residue (a demo storefront URL or phone). Replace the demo value(s) in ${gate.subject?.target_path || "_data/campaigns.json"}[${gate.subject?.public_route_slug || "<public-route-slug>"}]; only spec mismatches and missing values are waivable.`);
@@ -2710,7 +2889,7 @@ export function checkpointWaive(args) {
2710
2889
  const updated = appendCheckpointWaiver(report, waiver);
2711
2890
  updated.evidence = [
2712
2891
  ...(Array.isArray(report.evidence) ? report.evidence : []),
2713
- `Checkpoint waiver: ${gateId} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
2892
+ `Checkpoint waiver: ${gateId}${pageId ? ` (page ${pageId})` : ""} waived by ${waiver.waived_by} at ${waiver.waived_at}: ${waiver.reason}`,
2714
2893
  ];
2715
2894
  return updated;
2716
2895
  };
@@ -2731,6 +2910,7 @@ export function checkpointWaive(args) {
2731
2910
  dry_run: true,
2732
2911
  action: "checkpoint-waive",
2733
2912
  gate: gateId,
2913
+ ...(pageId ? { page: pageId } : {}),
2734
2914
  waiver,
2735
2915
  report_path: reportPath,
2736
2916
  would_write: reportPath,
@@ -2742,6 +2922,7 @@ export function checkpointWaive(args) {
2742
2922
  ...waiveReadiness(packetPath, reportPath),
2743
2923
  action: "checkpoint-waive",
2744
2924
  gate: gateId,
2925
+ ...(pageId ? { page: pageId } : {}),
2745
2926
  waiver,
2746
2927
  report_path: reportPath,
2747
2928
  note: "The exact checkpoint state is accepted under a bounded named-human exception and will report ready_with_waivers, never clean. Any state change makes this waiver stale and inert.",
@@ -2788,6 +2969,7 @@ export function pageKitSyncCommand(args) {
2788
2969
  changes: [],
2789
2970
  unchanged: [],
2790
2971
  not_in_spec: [],
2972
+ spec_empty_not_applied: [],
2791
2973
  not_synced: [],
2792
2974
  errors: [],
2793
2975
  warnings: [],
@@ -2930,6 +3112,7 @@ export function pageKitSyncCommand(args) {
2930
3112
  result.changes = plan.changes;
2931
3113
  result.unchanged = plan.unchanged;
2932
3114
  result.not_in_spec = plan.not_in_spec;
3115
+ result.spec_empty_not_applied = plan.spec_empty_not_applied;
2933
3116
  result.not_synced = plan.not_synced;
2934
3117
  for (const row of plan.not_synced) {
2935
3118
  addIssue(result.warnings, `page_kit.sync.${row.field}_not_synced`, `${row.field} was not written: ${row.detail}`, { reason: row.reason });
@@ -3738,7 +3921,12 @@ export function pageKitSyncTextLines(result) {
3738
3921
  lines.push("Changes: none (every governed field the spec carries already matches)");
3739
3922
  }
3740
3923
  if (result.unchanged?.length) lines.push(`Unchanged: ${result.unchanged.map((row) => row.field).join(", ")}`);
3741
- if (result.not_in_spec?.length) lines.push(`Not in spec (left as they are): ${result.not_in_spec.join(", ")}`);
3924
+ const specEmptyNotApplied = result.spec_empty_not_applied || [];
3925
+ const notCarried = (result.not_in_spec || []).filter((field) => !specEmptyNotApplied.includes(field));
3926
+ if (notCarried.length) lines.push(`Not in spec (left as they are): ${notCarried.join(", ")}`);
3927
+ if (specEmptyNotApplied.length) {
3928
+ lines.push(`Spec "" not applied (the target holds a real, non-demo value; sync blanks only a starter demo value, so remove it by hand if the merchant has none): ${specEmptyNotApplied.join(", ")}`);
3929
+ }
3742
3930
  if (result.warnings?.length) {
3743
3931
  lines.push("Warnings:");
3744
3932
  for (const issue of result.warnings) lines.push(`- ${formatIssueSummary(issue)}`);
@@ -3902,7 +4090,7 @@ export function nextStage(stage, args, ambient = null) {
3902
4090
  // and the recommendation is recorded on the active run session for
3903
4091
  // deviation telemetry.
3904
4092
  const prepareBuildRecoveryPrompt = divergences.length
3905
- ? `The assembly report's ledger and the repository's artifacts disagree (see divergences[]). Inspect both sides and decide which is right before acting. Do not rerun \`${cmd("prepare-build")}\` or \`${cmd("start")}\` on the strength of the ledger alone.`
4093
+ ? `The assembly report's ledger and the repository's artifacts disagree: ${quoteDivergences(divergences)} Inspect both sides and decide which is right before acting. Do not rerun \`${cmd("prepare-build")}\` or \`${cmd("start")}\` on the strength of the ledger alone.`
3906
4094
  : prepareBuildGate?.binding_failure
3907
4095
  ? prepareBuildGate.reason
3908
4096
  : prepareBuildGate?.stage
@@ -4119,6 +4307,9 @@ function withPacketSubstitutedIssue(issue, packetPath) {
4119
4307
  }
4120
4308
 
4121
4309
  function buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGate = prepareBuildGateIssue(report), packetPath = null }) {
4310
+ const checkpointGates = Array.isArray(doctor?.derived?.checkpoint_gates) ? doctor.derived.checkpoint_gates : [];
4311
+ const campaignCheckpointGates = checkpointGates.filter((gate) => !PER_PAGE_CHECKPOINT_GATES.has(gate?.id));
4312
+ const perPageGates = checkpointGates.filter((gate) => PER_PAGE_CHECKPOINT_GATES.has(gate?.id));
4122
4313
  return [
4123
4314
  {
4124
4315
  id: "doctor",
@@ -4130,9 +4321,7 @@ function buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGat
4130
4321
  status: prepareBuildGate ? "blocked" : "pass",
4131
4322
  reason: prepareBuildGate ? prepareBuildGate.reason : "prepare_build stage is terminal.",
4132
4323
  },
4133
- ...(Array.isArray(doctor?.derived?.checkpoint_gates)
4134
- ? doctor.derived.checkpoint_gates.map((gate) => withPacketSubstituted(gate, packetPath))
4135
- : []),
4324
+ ...campaignCheckpointGates.map((gate) => withPacketSubstituted(gate, packetPath)),
4136
4325
  ...(doctor?.derived?.polish_checkpoint_gate
4137
4326
  ? [withPacketSubstituted(doctor.derived.polish_checkpoint_gate, packetPath)]
4138
4327
  : []),
@@ -4154,6 +4343,11 @@ function buildNextGates({ doctor, report, themeGate, polishGate, prepareBuildGat
4154
4343
  waiver: polishGate?.waiver || null,
4155
4344
  required_actions: polishGate?.required_actions || [],
4156
4345
  }]),
4346
+ // Per-page gates last: one per page can outnumber the rest, and a
4347
+ // truncated projection of this list (progress keeps the first
4348
+ // PROGRESS_GATE_LIMIT, src/progress-node.mjs) must still carry every
4349
+ // campaign-wide gate.
4350
+ ...perPageGates.map((gate) => withPacketSubstituted(gate, packetPath)),
4157
4351
  ];
4158
4352
  }
4159
4353
 
@@ -4282,6 +4476,19 @@ function themeStarterPaletteAdvisory(themeGate, packetPath, residueState) {
4282
4476
  //
4283
4477
  // This action is emitted ALONE (see buildNextActions): a divergent packet is
4284
4478
  // a stop-and-reconcile state, not a stage with a recommended command.
4479
+ // Each divergence inline, so the count is never stated without the entries it
4480
+ // counts: text output renders only the action description, not divergences[].
4481
+ // The evidence quotes values the toolkit did not write (a deploy URL from the
4482
+ // report or packet, a verdict file's campaign_slug and verdict), so each field
4483
+ // is folded to one line before it joins the sentence. singleLineFragment, not
4484
+ // singleLineDetail: a path or URL keeps its exact characters (no Markdown
4485
+ // escapes) and a list of verdict files is not cut at a length budget.
4486
+ function quoteDivergences(divergences) {
4487
+ return divergences
4488
+ .map((divergence, index) => `(${index + 1}) ${singleLineFragment(divergence.stage)}: ledger claims ${singleLineFragment(divergence.ledger_claim)}; artifact evidence: ${singleLineFragment(divergence.artifact_evidence).replace(/\.?$/, ".")}`)
4489
+ .join(" ");
4490
+ }
4491
+
4285
4492
  function divergenceInspectAction(divergences, packetPath) {
4286
4493
  const divergedStages = divergences.map((divergence) => divergence.stage);
4287
4494
  const forwardHint = divergedStages.includes("qa")
@@ -4293,7 +4500,7 @@ function divergenceInspectAction(divergences, packetPath) {
4293
4500
  id: "divergence_inspect",
4294
4501
  kind: "manual",
4295
4502
  command: null,
4296
- description: `Ledger and artifacts disagree — ${divergences.length} divergence(s) recorded in divergences[]. This is the ONLY next action: stage actions are suppressed while the disagreement stands, because every one of them would be derived from the same contradictory evidence. Inspect both sides (each entry quotes the ledger claim and the artifact evidence) and decide which is right; update the assembly report only after inspection. Do not rerun start/prepare-build or redo completed-looking work on the strength of the ledger alone, and do not treat artifact presence as proof a stage is complete. ${forwardHint} Re-run \`${cmd("next")} --packet ${packetPath} --json\` once the report matches the artifacts to get the normal action list.`,
4503
+ description: `Ledger and artifacts disagree — ${divergences.length} divergence(s): ${quoteDivergences(divergences)} The same entries are the divergences[] field of \`${cmd("next")} --json\` output; they are not written to any file. This is the ONLY next action: stage actions are suppressed while the disagreement stands, because every one of them would be derived from the same contradictory evidence. Inspect both sides and decide which is right; update the assembly report only after inspection. Do not rerun start/prepare-build or redo completed-looking work on the strength of the ledger alone, and do not treat artifact presence as proof a stage is complete. ${forwardHint} Re-run \`${cmd("next")} --packet ${packetPath} --json\` once the report matches the artifacts to get the normal action list.`,
4297
4504
  required: true,
4298
4505
  };
4299
4506
  }
@@ -4422,9 +4629,9 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4422
4629
  }
4423
4630
  }
4424
4631
  if (result.stage === "setup") {
4425
- push("setup_skill", "skill", "next-campaigns-os-setup", "Prepare the target page-kit structure and agent context, then record stages.setup in the assembly report.");
4632
+ push("setup_skill", "skill", "next-campaigns-os-setup", `Prepare the target page-kit structure and agent context, then record setup with ${cmd("record")} setup --packet ${packetPath}.`);
4426
4633
  } else if (result.stage === "build") {
4427
- push("build_skill", "skill", "next-campaigns-build", "Assemble the campaign per the build prompt, then record stages.assembly in the assembly report.");
4634
+ push("build_skill", "skill", "next-campaigns-build", `Assemble the campaign per the build prompt, run the page-kit build, then record build with ${cmd("record")} build --packet ${packetPath}.`);
4428
4635
  if (isLocalServePacket(packet)) {
4429
4636
  push("build_local_proof", "command", LOCAL_PROOF_BUILD_COMMAND, `Local proof mode (deploy.target is local-serve): build page-kit in the ${LOCAL_PROOF_BUILD_ENVIRONMENT} environment into _site/ and record ${LOCAL_PROOF_BUILD_ENVIRONMENT_FIELD} as "${LOCAL_PROOF_BUILD_ENVIRONMENT}". Vendor loaders are environment-gated out of this render (their protocol-relative //host/... URLs fail over a plain-HTTP local serve); SDK dl_* events still fire. ${LOCAL_PROOF_NEVER_EDIT_RULE}`);
4430
4637
  push("build_production_parity", "command", asInvocation(substitutePacket(LOCAL_PROOF_PARITY_COMMAND, packetPath)), "After the development build is proven, assert the production render differs from it only in environment-gated output (same pages, route slugs, Campaign Cart pin and next-api-key) before committing; the PR preview is the second check.");
@@ -4435,7 +4642,7 @@ export function buildNextActions({ result, packetPath, packet, themeGate, polish
4435
4642
  }
4436
4643
  }
4437
4644
  } else if (result.stage === "polish") {
4438
- push("polish_skill", "skill", "next-campaigns-polish", "Run the visual polish pass, capture desktop/mobile evidence, then record stages.polish in the assembly report.");
4645
+ push("polish_skill", "skill", "next-campaigns-polish", `Run the visual polish pass and ${cmd("polish")} capture, then record polish with ${cmd("record")} polish --packet ${packetPath} --evidence <polish-evidence.json>.`);
4439
4646
  if (polishCheckpointGate?.status === "blocked") pushPolishCheckpointActions();
4440
4647
  } else if (result.stage === "deploy") {
4441
4648
  if (packet.deploy?.target === LOCAL_SERVE_DEPLOY_TARGET) {
@@ -4604,7 +4811,7 @@ Rules:
4604
4811
  - Replace demo refs; do not copy Olympus-style shipping_methods into shop-three-step.
4605
4812
  - 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.
4606
4813
  - 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.
4607
- - Run page-kit build and SDK/template lint, then update stages.assembly.status plus stages.assembly.build_fingerprint before polish. The fingerprint is computed from the built output, never typed: after page-kit build, run doctor --json and copy 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). If report.design_source_package.material_fingerprint exists, also record the same value on stages.assembly.source_package_material_fingerprint so Polish can prove the build used the current source context. Build must set stages.polish.status to "required" or "pending" with required_by="build" and required_for=["qa"]; Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, record report.theme.status, css_path, commerce_pages, load_order=after-next-core, evidence, and any repair-loop defect.
4814
+ - Run page-kit build and SDK/template lint, then record build before polish: \`${cmd("record")} build --packet ${packetPath}\`. It stamps stages.assembly.build_fingerprint with the fingerprint doctor computes from the built output (derived.build_output_fingerprint.value, sha256 over the sorted path+sha256 manifest of _site/<slug>/; doctor reports built_output.fingerprint_stale whenever the output on disk stops matching the recorded value), records report.design_source_package.material_fingerprint on stages.assembly.source_package_material_fingerprint when present, and sets stages.polish to "required" (required_by="build", required_for=["qa"]). Re-run it after every rebuild; never hand-edit these fields. Build must not mark stages.polish as completed/completed_with_warnings/skipped. If you applied a brand theme, record report.theme.status, css_path, commerce_pages, load_order=after-next-core, evidence, and any repair-loop defect.
4608
4815
  - 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)}`;
4609
4816
  }
4610
4817
 
@@ -4629,9 +4836,9 @@ Read first:
4629
4836
  - Target repo: ${packet.assembly.target_repo}
4630
4837
  - Output dir: ${packet.assembly.output_dir}
4631
4838
 
4632
- Prepare the target page-kit structure and agent context, then update setup status in both:
4633
- - .campaign-runtime/build-context.json scaffold.required/scaffold.mode/handoff fields
4634
- - .campaign-runtime/assembly-report.json stages.setup
4839
+ Prepare the target page-kit structure and agent context, then record setup:
4840
+ - ${cmd("record")} setup --packet ${packetPath}
4841
+ It sets .campaign-runtime/build-context.json scaffold.required=false and .campaign-runtime/assembly-report.json stages.setup to completed, validated against their schemas; do not hand-edit either file.
4635
4842
 
4636
4843
  When copying a starter template family, copy the family as an atomic page-kit slice: pages plus required _includes, _layouts, assets/css, and assets/js. Do not copy only checkout.html and receipt.html.
4637
4844
 
@@ -4656,13 +4863,10 @@ Before marking Polish complete, install the package-owned browser once and run t
4656
4863
 
4657
4864
  The producer covers every mapped route at fixed desktop/mobile viewports and attaches stages.polish.evidence.visual_review.page_load to the current Assembly Report. Never hand-author or copy page_load. A missing, stale, malformed, incomplete, cache/service-worker-observed, or over-threshold result keeps Polish/deploy/QA blocked; repair and recapture, or use the exact named-human checkpoint waiver only for a complete hidden eager-media finding.
4658
4865
 
4659
- Record Polish on stages.polish before QA:
4660
- - status: completed or completed_with_warnings (or blocked with blockers)
4661
- - performed_by: next-campaigns-polish
4662
- - source_build_fingerprint: the current stages.assembly.build_fingerprint
4663
- - source_package_material_fingerprint: the current report.design_source_package.material_fingerprint when present
4664
- - completed_at: ISO timestamp
4665
- - evidence.visual_review: representative screenshot paths/URLs plus package-generated page_load
4866
+ Record Polish before QA with ${cmd("record")} polish --packet ${packetPath} --evidence <polish-evidence.json>. It stamps performed_by (next-campaigns-polish), source_build_fingerprint (doctor's current build output fingerprint), source_package_material_fingerprint (when the report has one) and completed_at, keeps the captured page_load, and refuses a completed status, writing nothing, unless the polish gate would pass. The file is a JSON object:
4867
+ - status: completed or completed_with_warnings (default completed); or blocked with blockers (non-empty array of {code, message}), or skipped with skip_reason (string), evidence then optional; a blocked or skipped Polish keeps QA blocked
4868
+ - repair_loop_defect: optional; null or the first brand-layer repair-loop defect object, written to report.theme.repair_loop_defect
4869
+ - evidence.visual_review: object with representative screenshot paths/URLs (page_load comes from polish capture; never include it)
4666
4870
  - evidence.brand_review: logo/favicon/brand color checks, including non-template favicon confirmation
4667
4871
  - evidence.checkout_review: labels/placeholders, phone alignment, payment display, bump compare-price rule
4668
4872
  - evidence.template_residue_review: NEXT Blue/template placeholder/starter favicon/lorem/product residue checks
@@ -4759,7 +4963,7 @@ ${cmd("qa")} install-browser
4759
4963
  Node QA command:
4760
4964
  ${cmd("qa")} run --packet ${packetPath} --base-url ${url} --browser --test-order common
4761
4965
 
4762
- Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods, variant media, promo/urgency copy, pricing presentation, and trust/guarantee claims against the Campaign Build Brief. 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 checkout, first-offer accept/decline, and a deduplicated shortest real receipt path when that adds coverage (at most four orders); 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_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.
4966
+ Run the browser install once after install/update before --browser or --test-order. Test-order proof must exercise the campaign through the Campaign Cart SDK with the browser typed-card flow. Do not create hand-built backend API orders as launch proof. Compare visible placeholders, payment methods, variant media, promo/urgency copy, pricing presentation, and trust/guarantee claims against the Campaign Build Brief. 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_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.
4763
4967
 
4764
4968
  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.
4765
4969
 
@@ -4991,6 +5195,7 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
4991
5195
  source_directory: sourceDir,
4992
5196
  targets: targetResults,
4993
5197
  skills: targetResults.flatMap((target) => target.skills),
5198
+ read_now: targetResults.flatMap((target) => target.read_now),
4994
5199
  available_platforms: SKILL_PLATFORMS.map((platform) => ({
4995
5200
  platform: platform.id,
4996
5201
  label: platform.label,
@@ -4998,10 +5203,24 @@ function installSkills(targetArg = null, dryRun = false, platformArg = null) {
4998
5203
  })),
4999
5204
  note: dryRun
5000
5205
  ? "Dry run only; no skill files were written."
5001
- : "Restart local agent sessions to pick up new or updated skills.",
5206
+ : skillsReadNowNote(targetResults.flatMap((target) => target.read_now), "local agent sessions"),
5002
5207
  };
5003
5208
  }
5004
5209
 
5210
+ // A running agent does not load skills written after it started, and it cannot
5211
+ // restart itself, so the session that ran install-skills is told to read the
5212
+ // written SKILL.md files directly. A restart is the secondary route: it only
5213
+ // matters to sessions started later.
5214
+ // The same instruction wherever an action hands the agent the install-skills
5215
+ // command (tooling status), so no action tells it to restart first. No
5216
+ // parentheses: the install action's command must stay runnable as printed.
5217
+ const SKILLS_READ_NOW_FOLLOW_UP = "Then read the SKILL.md files install-skills lists under Read now in this session, because a running session does not load skills installed after it started; restart the agent only if it cannot read them.";
5218
+
5219
+ function skillsReadNowNote(readNow, sessionLabel) {
5220
+ if (!readNow.length) return "No skill files changed; nothing new to read.";
5221
+ return `Read these now in this session: the SKILL.md files listed under "Read now" (a running session does not load skills installed after it started). New ${sessionLabel} load them on their own.`;
5222
+ }
5223
+
5005
5224
  // A platform directory counts as installed when a skill already sits under one
5006
5225
  // of the bundled names (current or not), or our own copy under a retired name. A
5007
5226
  // slot install-skills would only create says nothing about that platform, and
@@ -5473,7 +5692,7 @@ function toolingCommand(args) {
5473
5692
  // Code, the documented install). The other platforms follow in a separate
5474
5693
  // sentence of prose: no `<a|b>` template or parenthesis a shell would read
5475
5694
  // as a redirect or a subshell if the command were copied with it.
5476
- actions.push(`Install bundled skills for the harness you use: ${cli.invocation_prefix} install-skills --platform claude. Use --platform codex for Codex, or --platform agents for shared agent skills such as Cursor's. Restart local agent sessions afterwards.`);
5695
+ actions.push(`Install bundled skills for the harness you use: ${cli.invocation_prefix} install-skills --platform claude. Use --platform codex for Codex, or --platform agents for shared agent skills such as Cursor's. ${SKILLS_READ_NOW_FOLLOW_UP}`);
5477
5696
  } else if (staleSkills.length) {
5478
5697
  const stalePlatforms = SKILL_PLATFORMS.map((platform) => platform.id)
5479
5698
  .filter((id) => staleSkills.some((skill) => skill.platform === id));
@@ -5483,7 +5702,7 @@ function toolingCommand(args) {
5483
5702
  ? stalePlatforms.map((platform) => ["--platform", platform])
5484
5703
  : [["--platform", args.platform || "all"]];
5485
5704
  const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
5486
- actions.push(`Refresh installed skills: ${commands.join(" and ")}. Restart local agent sessions afterwards.`);
5705
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
5487
5706
  }
5488
5707
 
5489
5708
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -5773,6 +5992,11 @@ function installSkillsToTarget({ sourceDir, target, dryRun, retired = [] }) {
5773
5992
  });
5774
5993
  }
5775
5994
 
5995
+ // The SKILL.md files this run wrote; an unchanged one is what was already
5996
+ // there for the session to load.
5997
+ const readNow = dryRun
5998
+ ? []
5999
+ : skills.filter((skill) => skill.action === "created" || skill.action === "updated").map((skill) => skill.destination);
5776
6000
  return {
5777
6001
  ok: true,
5778
6002
  status: dryRun ? "dry_run" : "installed",
@@ -5781,9 +6005,10 @@ function installSkillsToTarget({ sourceDir, target, dryRun, retired = [] }) {
5781
6005
  source_directory: sourceDir,
5782
6006
  target_directory: targetDir,
5783
6007
  skills,
6008
+ read_now: readNow,
5784
6009
  note: dryRun
5785
6010
  ? "Dry run only; no skill files were written."
5786
- : `Restart ${target.platform_label} session to pick up new or updated skills.`,
6011
+ : skillsReadNowNote(readNow, `${target.platform_label} sessions`),
5787
6012
  };
5788
6013
  }
5789
6014
 
@@ -7918,6 +8143,11 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
7918
8143
  if (result.dry_run) lines.push(`Would write: ${result.would_write} (nothing was written)`);
7919
8144
  if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
7920
8145
  }
8146
+ if (result.action === "record") {
8147
+ lines.push(`${result.dry_run ? "Would record" : "Recorded"}: ${result.stage}`);
8148
+ for (const path of result.dry_run ? result.would_write : result.written) lines.push(`${result.dry_run ? "Would write" : "Wrote"}: ${path}${result.dry_run ? " (nothing was written)" : ""}`);
8149
+ if (result.next_stage) lines.push(`Next stage: ${result.next_stage}${result.next_stage_reason ? ` (${result.next_stage_reason})` : ""}`);
8150
+ }
7921
8151
  if (result.targets?.length) {
7922
8152
  lines.push("Targets:");
7923
8153
  for (const target of result.targets) {
@@ -7930,6 +8160,10 @@ export function resultTextLines(result, { headerLines = [] } = {}) {
7930
8160
  lines.push("Skills:");
7931
8161
  for (const skill of result.skills) lines.push(`- ${formatSkillInstallSummary(skill)}`);
7932
8162
  }
8163
+ if (result.read_now?.length) {
8164
+ lines.push("Read now (read these now in this session):");
8165
+ for (const path of result.read_now) lines.push(`- ${path}`);
8166
+ }
7933
8167
  if (result.ready?.length) {
7934
8168
  lines.push("Ready:");
7935
8169
  for (const item of result.ready) lines.push(`- ${item}`);