@nextcommerce/campaigns-os 1.52.0 → 1.53.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 (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. 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, 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,30 +277,72 @@ 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
+ if (detected) {
335
+ const cause = detected === "spec_material_changed" ? "spec" : "presentation";
336
+ const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
337
+ return { report: demoted.report, context: null };
338
+ }
288
339
  const previousPolish = stageObject(report, "polish");
289
340
  const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
290
341
  && optionalString(previousPolish.source_build_fingerprint) === fingerprint;
291
342
  const polish = polishStillCurrent
292
343
  ? previousPolish
293
344
  : {
294
- ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by"]),
345
+ ...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", ...STAMP_FIELDS]),
295
346
  stage: "polish",
296
347
  status: "required",
297
348
  required_by: "build",
@@ -300,8 +351,11 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
300
351
  return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
301
352
  }
302
353
 
303
- function composePolish(report, { now, recordedBy, fingerprint, input }) {
354
+ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
304
355
  const previous = stageObject(report, "polish");
356
+ const completed = POLISH_COMPLETED_STATUSES.includes(input.status);
357
+ const writeInputs = stageWriteInputs("polish", inputs);
358
+ const detected = REPLACED_COMPLETED_STATUSES.includes(previous.status) ? writeInputs.detectChange(previous) : null;
305
359
  const previousVisual = isObject(previous.evidence?.visual_review) ? previous.evidence.visual_review : {};
306
360
  // A blocked or skipped record given no evidence keeps what is there (the
307
361
  // capture's bounded evidence stays for diagnosis).
@@ -315,13 +369,16 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
315
369
  }
316
370
  : previous.evidence;
317
371
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
318
- const polish = {
319
- ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence"]),
372
+ let polish = {
373
+ ...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", ...STAMP_FIELDS]),
320
374
  stage: "polish",
321
375
  status: input.status,
322
376
  performed_by: POLISH_PRODUCER,
323
377
  source_build_fingerprint: fingerprint,
324
378
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
379
+ // Only a completed Polish is proof against inputs; a skip or a block
380
+ // carries no stamp.
381
+ ...(completed ? currentStamps(inputs) : {}),
325
382
  // A blocked Polish has not completed.
326
383
  ...(input.status === "blocked" ? {} : { completed_at: now }),
327
384
  recorded_by: recordedBy,
@@ -329,6 +386,15 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
329
386
  ...(input.skipReason ? { skip_reason: input.skipReason } : {}),
330
387
  blockers: input.blockers,
331
388
  };
389
+ if (detected) polish.input_change = inputChangeFor("polish", previous, { at: now, reason: detected });
390
+ // The change is cleared only by a completed record stamped with the current
391
+ // inputs whose attached package capture postdates it; any other write
392
+ // (a skip included) carries it.
393
+ if (completed && isObject(polish.input_change) && writeInputs.stampsCurrent(polish)) {
394
+ const capturedAt = polish.evidence?.visual_review?.page_load?.captured_at;
395
+ if (typeof capturedAt === "string" && Date.parse(capturedAt) > Date.parse(polish.input_change.at)) delete polish.input_change;
396
+ }
397
+ polish = archiveStageRecord(polish, previous, polish, { by: "record polish", reason: detected || "rerecorded", at: now, stageKey: "polish" });
332
398
  const nextReport = { ...report, stages: { ...report.stages, polish } };
333
399
  // A null defect on a report with no theme block says nothing to record.
334
400
  if (input.hasRepairLoopDefect && (isObject(report.theme) || input.repairLoopDefect !== null)) {
@@ -618,10 +684,14 @@ function ladderProblems(stage, doctor, report) {
618
684
  // The rule next's stage picker reads: on the local preview a missing polish
619
685
  // is carried forward (local-preview-policy), and next moves on to deploy.
620
686
  if (earlier === "polish" && doctor.derived?.polish_gate?.status === CARRIED_FORWARD) continue;
687
+ // The effective status: a stage owed again by an input change reads
688
+ // required, and one whose inputs cannot be confirmed reads unknown.
621
689
  const key = reportKeyForCliStage(earlier);
622
690
  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.`);
691
+ const effective = effectiveStageStatus(key, report.stages[key], doctor.derived?.input_currency);
692
+ if (!effectiveStatusIsTerminal(effective)) {
693
+ const why = effective === status ? "" : ` (recorded "${status || "(unset)"}"; ${doctor.derived?.input_currency?.reasons?.[key] || "inputs not confirmed"})`;
694
+ problems.push(`stages.${key}.status is "${effective || "(unset)"}"${why}, so next answers ${earlier}; run ${cmd("record")} ${earlier} first.`);
625
695
  }
626
696
  }
627
697
  return problems;
@@ -709,7 +779,24 @@ function deployFacts(doctor, packet, probe) {
709
779
  * records through recordStageCommand.
710
780
  */
711
781
  export async function recordCommand(args, options = {}) {
712
- const { stage, packetPath, baseUrl } = parseRecordArgs(args);
782
+ const { stage, packetPath, baseUrl, briefPath, dryRun } = parseRecordArgs(args);
783
+ if (stage === "brief") {
784
+ return recordBrief({
785
+ packetPath,
786
+ briefPath,
787
+ contextPath: args.context ? resolve(args.context) : undefined,
788
+ reportPath: args.report ? resolve(args.report) : undefined,
789
+ dryRun,
790
+ }, options.now ? { now: options.now } : {});
791
+ }
792
+ if (stage === "spec") {
793
+ return recordSpec({
794
+ packetPath,
795
+ contextPath: args.context ? resolve(args.context) : undefined,
796
+ reportPath: args.report ? resolve(args.report) : undefined,
797
+ dryRun,
798
+ }, options.now ? { now: options.now } : {});
799
+ }
713
800
  const probe = stage === "deploy" && existsSync(packetPath)
714
801
  ? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
715
802
  : null;
@@ -735,7 +822,7 @@ function readPacketFile(stage, packetPath) {
735
822
  * composed or written.
736
823
  */
737
824
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
738
- const { stage, packetPath, evidencePath, dryRun, buildEnvironment } = parseRecordArgs(args);
825
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, deviationReason } = parseRecordArgs(args);
739
826
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
740
827
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
741
828
  // Operator input, not target state: no campaigns-os writer produces it.
@@ -756,7 +843,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
756
843
  // A dry run writes nothing, so it takes no lock and creates no lock files
757
844
  // (the commitAssemblyReport preview convention); it reads in the same order.
758
845
  const run = () => recordUnderLock({
759
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment,
846
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, deviationReason,
760
847
  });
761
848
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
762
849
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
@@ -806,7 +893,13 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
806
893
  // re-check, and the post-write doctor read for `next_stage`. No campaigns-os
807
894
  // writer can rebind, rewrite or republish any of them between the read and
808
895
  // the write.
809
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null }) {
896
+ // The inputs current at this record, as doctor read them under the lock.
897
+ function recordInputs(doctor) {
898
+ const currency = doctor.derived?.input_currency;
899
+ return { briefMaterial: currency?.brief?.current ?? null, specMaterial: currency?.spec?.current ?? null };
900
+ }
901
+
902
+ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment = null, deviationReason = null }) {
810
903
  // The same workspace `next` resolves, so the record lands in the report
811
904
  // `next` reads now, not the one it read before the lock was free.
812
905
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -841,13 +934,13 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
841
934
  const next = stage === "setup"
842
935
  ? composeSetup(report, context, { now: timestamp, recordedBy })
843
936
  : stage === "build"
844
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment })
937
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, inputs: recordInputs(doctor), deviationReason })
845
938
  : stage === "theme"
846
939
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
847
940
  : stage === "deploy"
848
941
  ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
849
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
850
- applyDerivedAssemblyReportSummary(next.report);
942
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
943
+ applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
851
944
  // The packet's one new value, deploy.preview_url, is checked by
852
945
  // localPreviewUrl; the rest of the packet is as the operator left it.
853
946
  validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
@@ -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") : "";