@nextcommerce/campaigns-os 1.52.0 → 1.54.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 (76) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +1 -0
  3. package/agents/claude/CLAUDE.md +2 -0
  4. package/agents/codex/AGENTS.md +2 -0
  5. package/agents/copilot/copilot-instructions.md +2 -0
  6. package/agents/cursor/campaigns-os.mdc +2 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -27
  11. package/contracts/effects.v1.json +295 -47
  12. package/contracts/release-ledger.json +660 -0
  13. package/contracts/supported-surface.json +3 -3
  14. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  15. package/docs/brand-theme-bridge.md +2 -0
  16. package/docs/build-packet.md +67 -19
  17. package/docs/campaign-build-brief.md +48 -17
  18. package/docs/campaign-standardization-report.md +32 -0
  19. package/docs/design-source-package.md +7 -3
  20. package/docs/effects.md +10 -1
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +2 -0
  24. package/docs/progress-snapshots.md +5 -5
  25. package/docs/qa-and-test-orders.md +30 -3
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +15 -0
  29. package/docs/skills-revision.md +10 -10
  30. package/docs/supported-surface.md +1 -0
  31. package/package.json +1 -1
  32. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  33. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  34. package/skills/campaign-readback-classification/SKILL.md +3 -3
  35. package/skills/campaign-run-evidence/SKILL.md +3 -3
  36. package/skills/contribution-intake/SKILL.md +3 -3
  37. package/skills/next-campaigns-build/SKILL.md +5 -3
  38. package/skills/next-campaigns-os/SKILL.md +10 -19
  39. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  40. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  41. package/skills/next-campaigns-polish/SKILL.md +7 -5
  42. package/skills/next-campaigns-qa/SKILL.md +5 -3
  43. package/skills.json +10 -10
  44. package/src/brand-theme.mjs +21 -20
  45. package/src/brief-summary.mjs +275 -0
  46. package/src/build-brief.mjs +167 -15
  47. package/src/campaign-ecosystem.mjs +153 -31
  48. package/src/cli.mjs +182 -115
  49. package/src/contrast.mjs +649 -0
  50. package/src/design-source-publication.mjs +110 -3
  51. package/src/directory-lock.mjs +79 -7
  52. package/src/doctor/checks.mjs +141 -16
  53. package/src/doctor/inspect.mjs +13 -0
  54. package/src/doctor/next-step.mjs +15 -6
  55. package/src/input-currency.mjs +394 -0
  56. package/src/input-refresh.mjs +708 -0
  57. package/src/invocation.mjs +6 -2
  58. package/src/polish-browser.mjs +503 -201
  59. package/src/polish-deadline.mjs +7 -0
  60. package/src/polish-node.mjs +326 -46
  61. package/src/polish-readability.mjs +828 -0
  62. package/src/progress-node.mjs +18 -5
  63. package/src/qa-browser.mjs +211 -93
  64. package/src/qa-node.mjs +47 -7
  65. package/src/qc-accept.mjs +44 -8
  66. package/src/qc-check-registry.mjs +15 -7
  67. package/src/qc-results.mjs +326 -34
  68. package/src/readback.mjs +58 -10
  69. package/src/sdk-attribute-index.mjs +111 -23
  70. package/src/sdk-markup.mjs +49 -10
  71. package/src/sdk-repin.mjs +282 -0
  72. package/src/stage-ledger.mjs +235 -44
  73. package/src/stage-record.mjs +154 -30
  74. package/src/standardization-report.mjs +26 -1
  75. package/src/target-lock.mjs +9 -1
  76. package/src/tooling-setup.mjs +1 -1
@@ -46,7 +46,9 @@ import {
46
46
  evaluatePolishGate,
47
47
  } from "./polish-gate.mjs";
48
48
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
49
- import { applyDerivedAssemblyReportSummary, assemblyReportMatchesPacket, commitAssemblyReport } from "./stage-ledger.mjs";
49
+ import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
50
+ import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
51
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
50
52
  import { withTargetLockSync } from "./target-lock.mjs";
51
53
  import { commerceScopeFromScope } from "./theme-gate.mjs";
52
54
 
@@ -59,6 +61,11 @@ const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "j
59
61
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
60
62
  const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
61
63
  const BUILD_RECORD_FLAGS = Object.freeze(["build-environment"]);
64
+ // `record brief` saves a brief file: --brief names it (otherwise intake's
65
+ // discovery finds it).
66
+ const BRIEF_RECORD_FLAGS = Object.freeze(["brief"]);
67
+ // `record spec` binds the CampaignSpec as it is now; it takes no extra flag.
68
+ const RECORD_SUBCOMMANDS = Object.freeze([...RECORD_STAGES, "brief", "spec"]);
62
69
  // The page-kit environment the built output was rendered in, recorded on
63
70
  // stages.assembly.evidence.build_environment (local proof mode builds in
64
71
  // development; doctor and page-kit parity read it).
@@ -116,24 +123,24 @@ function typeName(value) {
116
123
 
117
124
  // visual_review keys only `polish capture` writes. `record polish --evidence`
118
125
  // refuses them by name and carries the captured values forward.
119
- export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight"]);
126
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
120
127
  export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
121
128
 
122
- function refuseRecord(stage, problems) {
129
+ export function refuseRecord(stage, problems) {
123
130
  return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
124
131
  }
125
132
 
126
133
  export function parseRecordArgs(args) {
127
134
  const stage = args._[1];
128
- if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
129
- throw refused(`Use: ${cmd("record")} <${RECORD_STAGES.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, and record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>].`);
135
+ if (!RECORD_SUBCOMMANDS.includes(stage) || args._.length !== 2) {
136
+ throw refused(`Use: ${cmd("record")} <${RECORD_SUBCOMMANDS.join("|")}> --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--dry-run] [--json]; record polish also takes --evidence <polish-evidence.json>, record deploy --base-url <served url>, record build [--build-environment <${BUILD_ENVIRONMENTS.join("|")}>], and record brief [--brief <yaml|json>].`);
130
137
  }
131
- const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : [])]);
138
+ const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_RECORD_FLAGS : []), ...(stage === "build" ? BUILD_RECORD_FLAGS : []), ...(stage === "brief" ? BRIEF_RECORD_FLAGS : [])]);
132
139
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
133
140
  if (unknown.length) {
134
141
  throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
135
142
  }
136
- for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason"]) {
143
+ for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason", "brief"]) {
137
144
  if (Object.hasOwn(args, flag)) requireArg(args, flag);
138
145
  }
139
146
  if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
@@ -151,6 +158,8 @@ export function parseRecordArgs(args) {
151
158
  baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
152
159
  dryRun: args["dry-run"] === true,
153
160
  buildEnvironment,
161
+ briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
162
+ deviationReason: optionalString(args["deviation-reason"]),
154
163
  };
155
164
  }
156
165
 
@@ -268,40 +277,111 @@ function composeSetup(report, context, { now, recordedBy }) {
268
277
  return { report: nextReport, context: nextContext };
269
278
  }
270
279
 
271
- function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null }) {
280
+ // The fields a record restates on every write: its input stamps, and the
281
+ // operator decision that applies only to the record that carried it.
282
+ const STAMP_FIELDS = Object.freeze(["source_brief_material", "source_spec_material_hash"]);
283
+ // A replaced record in any of these statuses is checked for an input change.
284
+ const REPLACED_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
285
+
286
+ // The stamps a completed record writes: the inputs current at this write
287
+ // (the absent brief value for a packet without a brief). A value that cannot
288
+ // be computed is left out, so the record reads unknown rather than current.
289
+ function currentStamps(inputs) {
290
+ const stamps = inputStamps(inputs);
291
+ return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
292
+ }
293
+
294
+ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, inputs = {}, deviationReason = null }) {
272
295
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
273
296
  const previousAssembly = stageObject(report, "assembly");
274
- const assembly = {
275
- ...withoutKeys(previousAssembly, ["source_package_material_fingerprint"]),
297
+ const stamps = currentStamps(inputs);
298
+ // A replaced completed record whose stamps differ from the current inputs
299
+ // records the change now, if nothing recorded it before.
300
+ const writeInputs = stageWriteInputs("assembly", inputs);
301
+ const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
302
+ let assembly = {
303
+ ...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", ...STAMP_FIELDS]),
276
304
  ...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
277
305
  stage: "assembly",
278
306
  status: "completed",
279
307
  build_fingerprint: fingerprint,
280
308
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
309
+ ...stamps,
281
310
  completed_at: now,
282
311
  recorded_by: recordedBy,
283
312
  blockers: [],
284
313
  };
285
- // Polish evidence bound to this exact output stays; anything else is owed
286
- // again. The evidence object is kept so `polish capture` has somewhere to
287
- // attach page_load, and its stale identity fields are removed.
314
+ if (detected) assembly.input_change = inputChangeFor("assembly", previousAssembly, { at: now, reason: detected });
315
+ // Output identical to the build the input change superseded does not make
316
+ // the build current, unless the operator's --deviation-reason records that
317
+ // the change needs no output change (completed_with_warnings).
318
+ const replayed = isObject(assembly.input_change) && assembly.input_change.superseded_build_fingerprint === fingerprint;
319
+ if (replayed && deviationReason) {
320
+ assembly.status = "completed_with_warnings";
321
+ assembly.unchanged_output_reason = {
322
+ text: deviationReason,
323
+ for_inputs: { brief_material: stamps.source_brief_material ?? null, spec_material_hash: stamps.source_spec_material_hash ?? null },
324
+ };
325
+ }
326
+ if (!replayed || deviationReason) {
327
+ if (writeInputs.stampsCurrent(assembly)) delete assembly.input_change;
328
+ }
329
+ assembly = archiveStageRecord(assembly, previousAssembly, assembly, { by: "record build", reason: detected || "rerecorded", at: now, stageKey: "assembly" });
330
+ // A detected change makes Polish and QA owed through the dependency map.
331
+ // Otherwise Polish evidence bound to this exact output stays and anything
332
+ // else is owed again; the evidence object is kept so `polish capture` has
333
+ // somewhere to attach page_load, and its stale identity fields are removed.
334
+ // A deploy that stamped the build it probed is owed again once the output
335
+ // differs; one with no stamp (recorded before deploy stamped it) is kept.
336
+ const deployOwed = deployAfterBuild(stageObject(report, "deploy"), fingerprint, now);
337
+ const deploy = deployOwed ? { deploy: deployOwed } : {};
338
+ if (detected) {
339
+ const cause = detected === "spec_material_changed" ? "spec" : "presentation";
340
+ const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
341
+ return { report: demoted.report, context: null };
342
+ }
288
343
  const previousPolish = stageObject(report, "polish");
289
344
  const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
290
345
  && optionalString(previousPolish.source_build_fingerprint) === fingerprint;
291
346
  const polish = polishStillCurrent
292
347
  ? previousPolish
293
348
  : {
294
- ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by"]),
349
+ ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", ...STAMP_FIELDS]),
295
350
  stage: "polish",
296
351
  status: "required",
297
352
  required_by: "build",
298
353
  required_for: ["qa"],
299
354
  };
300
- return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
355
+ return { report: { ...report, stages: { ...report.stages, assembly, polish, ...deploy } }, context: null };
301
356
  }
302
357
 
303
- function composePolish(report, { now, recordedBy, fingerprint, input }) {
358
+ // stages.deploy after a build of `fingerprint`, or null when it is kept: a
359
+ // completed deploy whose source_build_fingerprint names other output goes
360
+ // whole into history (archived_by "record build", reason_code
361
+ // "build_output_changed") and reads required (required_by "build",
362
+ // required_for ["qa"]) until record deploy probes this build. As a demotion
363
+ // does, the live record loses what was written by the completion (completed_at,
364
+ // recorded_by, performed_by) and what describes the old output's probe
365
+ // (source_build_fingerprint, evidence, outputs); blockers, warnings, inputs
366
+ // and commands are kept.
367
+ function deployAfterBuild(previousDeploy, fingerprint, now) {
368
+ const stamped = optionalString(previousDeploy.source_build_fingerprint);
369
+ if (!stamped || stamped === fingerprint || !REPLACED_COMPLETED_STATUSES.includes(previousDeploy.status)) return null;
370
+ const demoted = {
371
+ ...withoutKeys(previousDeploy, ["source_build_fingerprint", "evidence", "outputs", "completed_at", "recorded_by", "performed_by"]),
372
+ stage: "deploy",
373
+ status: "required",
374
+ required_by: "build",
375
+ required_for: ["qa"],
376
+ };
377
+ return archiveSupersededRecord(demoted, previousDeploy, { by: "record build", reason: "build_output_changed", at: now });
378
+ }
379
+
380
+ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
304
381
  const previous = stageObject(report, "polish");
382
+ const completed = POLISH_COMPLETED_STATUSES.includes(input.status);
383
+ const writeInputs = stageWriteInputs("polish", inputs);
384
+ const detected = REPLACED_COMPLETED_STATUSES.includes(previous.status) ? writeInputs.detectChange(previous) : null;
305
385
  const previousVisual = isObject(previous.evidence?.visual_review) ? previous.evidence.visual_review : {};
306
386
  // A blocked or skipped record given no evidence keeps what is there (the
307
387
  // capture's bounded evidence stays for diagnosis).
@@ -315,13 +395,16 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
315
395
  }
316
396
  : previous.evidence;
317
397
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
318
- const polish = {
319
- ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence"]),
398
+ let polish = {
399
+ ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", ...STAMP_FIELDS]),
320
400
  stage: "polish",
321
401
  status: input.status,
322
402
  performed_by: POLISH_PRODUCER,
323
403
  source_build_fingerprint: fingerprint,
324
404
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
405
+ // Only a completed Polish is proof against inputs; a skip or a block
406
+ // carries no stamp.
407
+ ...(completed ? currentStamps(inputs) : {}),
325
408
  // A blocked Polish has not completed.
326
409
  ...(input.status === "blocked" ? {} : { completed_at: now }),
327
410
  recorded_by: recordedBy,
@@ -329,6 +412,15 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
329
412
  ...(input.skipReason ? { skip_reason: input.skipReason } : {}),
330
413
  blockers: input.blockers,
331
414
  };
415
+ if (detected) polish.input_change = inputChangeFor("polish", previous, { at: now, reason: detected });
416
+ // The change is cleared only by a completed record stamped with the current
417
+ // inputs whose attached package capture postdates it; any other write
418
+ // (a skip included) carries it.
419
+ if (completed && isObject(polish.input_change) && writeInputs.stampsCurrent(polish)) {
420
+ const capturedAt = polish.evidence?.visual_review?.page_load?.captured_at;
421
+ if (typeof capturedAt === "string" && Date.parse(capturedAt) > Date.parse(polish.input_change.at)) delete polish.input_change;
422
+ }
423
+ polish = archiveStageRecord(polish, previous, polish, { by: "record polish", reason: detected || "rerecorded", at: now, stageKey: "polish" });
332
424
  const nextReport = { ...report, stages: { ...report.stages, polish } };
333
425
  // A null defect on a report with no theme block says nothing to record.
334
426
  if (input.hasRepairLoopDefect && (isObject(report.theme) || input.repairLoopDefect !== null)) {
@@ -538,12 +630,16 @@ export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globa
538
630
  }
539
631
 
540
632
  // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
541
- // completed with the URL in outputs (what next reads) and the probe as evidence.
542
- function composeDeploy(report, packet, { now, recordedBy, probe }) {
633
+ // completed with the URL in outputs (what next reads), the probe as evidence,
634
+ // and the build it probed as source_build_fingerprint (doctorFacts has checked
635
+ // it equals stages.assembly.build_fingerprint), so a later record build of
636
+ // different output makes deploy required again, as it does Polish.
637
+ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint }) {
543
638
  const deploy = {
544
639
  ...stageObject(report, "deploy"),
545
640
  stage: "deploy",
546
641
  status: "completed",
642
+ source_build_fingerprint: fingerprint,
547
643
  outputs: [probe.url],
548
644
  evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
549
645
  completed_at: now,
@@ -618,10 +714,14 @@ function ladderProblems(stage, doctor, report) {
618
714
  // The rule next's stage picker reads: on the local preview a missing polish
619
715
  // is carried forward (local-preview-policy), and next moves on to deploy.
620
716
  if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
717
+ // The effective status: a stage owed again by an input change reads
718
+ // required, and one whose inputs cannot be confirmed reads unknown.
621
719
  const key = reportKeyForCliStage(earlier);
622
720
  const status = String(report.stages[key]?.status || "");
623
- if (!stageIsTerminal(status)) {
624
- problems.push(`stages.${key}.status is "${status || "(unset)"}", so next answers ${earlier}; run ${cmd("record")} ${earlier} first.`);
721
+ const effective = effectiveStageStatus(key, report.stages[key], doctor.derived?.input_currency);
722
+ if (!effectiveStatusIsTerminal(effective)) {
723
+ const why = effective === status ? "" : ` (recorded "${status || "(unset)"}"; ${doctor.derived?.input_currency?.reasons?.[key] || "inputs not confirmed"})`;
724
+ problems.push(`stages.${key}.status is "${effective || "(unset)"}"${why}, so next answers ${earlier}; run ${cmd("record")} ${earlier} first.`);
625
725
  }
626
726
  }
627
727
  return problems;
@@ -709,7 +809,24 @@ function deployFacts(doctor, packet, probe) {
709
809
  * records through recordStageCommand.
710
810
  */
711
811
  export async function recordCommand(args, options = {}) {
712
- const { stage, packetPath, baseUrl } = parseRecordArgs(args);
812
+ const { stage, packetPath, baseUrl, briefPath, dryRun } = parseRecordArgs(args);
813
+ if (stage === "brief") {
814
+ return recordBrief({
815
+ packetPath,
816
+ briefPath,
817
+ contextPath: args.context ? resolve(args.context) : undefined,
818
+ reportPath: args.report ? resolve(args.report) : undefined,
819
+ dryRun,
820
+ }, options.now ? { now: options.now } : {});
821
+ }
822
+ if (stage === "spec") {
823
+ return recordSpec({
824
+ packetPath,
825
+ contextPath: args.context ? resolve(args.context) : undefined,
826
+ reportPath: args.report ? resolve(args.report) : undefined,
827
+ dryRun,
828
+ }, options.now ? { now: options.now } : {});
829
+ }
713
830
  const probe = stage === "deploy" && existsSync(packetPath)
714
831
  ? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
715
832
  : null;
@@ -735,7 +852,7 @@ function readPacketFile(stage, packetPath) {
735
852
  * composed or written.
736
853
  */
737
854
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
738
- const { stage, packetPath, evidencePath, dryRun, buildEnvironment } = parseRecordArgs(args);
855
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, deviationReason } = parseRecordArgs(args);
739
856
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
740
857
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
741
858
  // Operator input, not target state: no campaigns-os writer produces it.
@@ -756,7 +873,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
756
873
  // A dry run writes nothing, so it takes no lock and creates no lock files
757
874
  // (the commitAssemblyReport preview convention); it reads in the same order.
758
875
  const run = () => recordUnderLock({
759
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment,
876
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, deviationReason,
760
877
  });
761
878
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
762
879
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
@@ -775,6 +892,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
775
892
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
776
893
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
777
894
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
895
+ ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
778
896
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
779
897
  ];
780
898
  return {
@@ -806,7 +924,13 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
806
924
  // re-check, and the post-write doctor read for `next_stage`. No campaigns-os
807
925
  // writer can rebind, rewrite or republish any of them between the read and
808
926
  // the write.
809
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null }) {
927
+ // The inputs current at this record, as doctor read them under the lock.
928
+ function recordInputs(doctor) {
929
+ const currency = doctor.derived?.input_currency;
930
+ return { briefMaterial: currency?.brief?.current ?? null, specMaterial: currency?.spec?.current ?? null };
931
+ }
932
+
933
+ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, deviationReason = null }) {
810
934
  // The same workspace `next` resolves, so the record lands in the report
811
935
  // `next` reads now, not the one it read before the lock was free.
812
936
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -841,13 +965,13 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
841
965
  const next = stage === "setup"
842
966
  ? composeSetup(report, context, { now: timestamp, recordedBy })
843
967
  : stage === "build"
844
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment })
968
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, inputs: recordInputs(doctor), deviationReason })
845
969
  : stage === "theme"
846
970
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
847
971
  : stage === "deploy"
848
- ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
849
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
850
- applyDerivedAssemblyReportSummary(next.report);
972
+ ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input, fingerprint: facts.fingerprint })
973
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
974
+ applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
851
975
  // The packet's one new value, deploy.preview_url, is checked by
852
976
  // localPreviewUrl; the rest of the packet is as the operator left it.
853
977
  validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
@@ -24,6 +24,7 @@ import {
24
24
  loadSdkSupportPolicy,
25
25
  scanCampaignCartAppRoot,
26
26
  } from "./campaign-ecosystem.mjs";
27
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
27
28
  import { resolveCommerceCatalog } from "./private-template-source.mjs";
28
29
  import {
29
30
  assessTemplateFreshness,
@@ -375,7 +376,9 @@ function scanPageKitRoot({
375
376
  const bindingFiles = sourceScan.binding_files;
376
377
  let checkoutFields = null;
377
378
  if (bindingFiles.length) {
378
- checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings);
379
+ checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings, {
380
+ sdkVersionForFile: campaignsSdkVersionResolver(campaigns.slugs),
381
+ });
379
382
  capabilities.push("checkout_field_contract");
380
383
  }
381
384
  const root = {
@@ -504,6 +507,28 @@ function isPageKitRoot(dir) {
504
507
  return Boolean(findPageKitDependency(pkg.value));
505
508
  }
506
509
 
510
+ // The SDK version a Page Kit source file's checkout bindings run against: its
511
+ // campaign's campaigns.json sdk_version (src/<slug>/...), else, for a file no
512
+ // one campaign owns, the lowest pin across campaigns. Unknown (null) when that
513
+ // pin is missing or not an exact release (isReleasedSdkVersion, as doctor
514
+ // reads the same pin), or when any campaign lacks one; the field contract
515
+ // judges unknown as an earlier SDK.
516
+ function campaignsSdkVersionResolver(slugs) {
517
+ const exact = new Map(slugs.map((entry) => [entry.slug, isReleasedSdkVersion(entry.sdk_version) ? entry.sdk_version : null]));
518
+ const pins = [...exact.values()];
519
+ const lowest = pins.length && pins.every(Boolean)
520
+ ? { version: [...pins].sort(compareVersions)[0], source: "campaigns_json" }
521
+ : null;
522
+ return (path) => {
523
+ const [top, slug] = String(path).split("/");
524
+ if (top === "src" && exact.has(slug)) {
525
+ const version = exact.get(slug);
526
+ return version ? { version, source: "campaigns_json" } : null;
527
+ }
528
+ return lowest;
529
+ };
530
+ }
531
+
507
532
  function readCampaigns(rootPath) {
508
533
  const campaignsPath = join(rootPath, "_data", "campaigns.json");
509
534
  if (!existsSync(campaignsPath)) {
@@ -21,9 +21,17 @@ export function targetLockPath(targetRepo) {
21
21
  // `command` names the waiting command. The lock does not record which command
22
22
  // holds it, only a pid, so the holder is described generically. A lock with
23
23
  // no owner record is called out on its own: it is never taken over, and the
24
- // operator needs to know it will not clear by waiting.
24
+ // operator needs to know it will not clear by waiting. So is anything at the
25
+ // lock path that is not a lock at all, named by what it is (#514).
25
26
  function unavailable(targetRepo, lockPath, command) {
26
27
  return (error) => {
28
+ if (error?.code === "ENOTLOCK") {
29
+ return Object.assign(new Error(
30
+ `${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
31
+ + "It is never removed automatically. Move it out of the way, then retry.",
32
+ { cause: error },
33
+ ), { code: error.code, obstruction: error.obstruction });
34
+ }
27
35
  if (error?.code !== "EEXIST") {
28
36
  return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
29
37
  }
@@ -119,7 +119,7 @@ export function setupTooling(args, { packageRoot, installSkills, installAgentCon
119
119
  regularDestination(target, dest);
120
120
  const source = { "CLAUDE.md": "agents/claude/CLAUDE.md", "AGENTS.md": "agents/codex/AGENTS.md", "campaigns-os.mdc": "agents/cursor/campaigns-os.mdc", "copilot-instructions.md": "agents/copilot/copilot-instructions.md" }[name];
121
121
  if (existsSync(dest) && readFileSync(dest, "utf8") !== readFileSync(join(packageRoot, source), "utf8")) {
122
- throw new Error(`tooling setup: ${dest} differs from this toolkit's context. Preserve and reconcile it before rerunning setup; no files were changed.`);
122
+ throw new Error(`tooling setup: ${dest} differs from this toolkit's context. Preserve and reconcile it before rerunning setup; no files were changed. Refresh it with \`install-agent-context --target .\` (overwrites all four files; review the diff), then rerun setup.`);
123
123
  }
124
124
  }
125
125
  const prior = existsSync(instructions) ? readFileSync(instructions, "utf8") : "";