@nextcommerce/campaigns-os 1.50.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 (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +282 -14
  13. package/contracts/release-ledger.json +1233 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -29,7 +29,8 @@ import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
29
29
  import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
30
30
  import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
31
31
  import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
32
- import { isLocalServePacket } from "./local-proof.mjs";
32
+ import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
33
+ import { CARRIED_FORWARD } from "./local-preview-policy.mjs";
33
34
  import { isLoopbackHostname } from "./remit.mjs";
34
35
  import { campaignRouteRoot } from "./route-identity.mjs";
35
36
  import { writeJsonAtomic } from "./doctor-sidecar.mjs";
@@ -45,17 +46,30 @@ import {
45
46
  evaluatePolishGate,
46
47
  } from "./polish-gate.mjs";
47
48
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
48
- 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";
49
52
  import { withTargetLockSync } from "./target-lock.mjs";
50
53
  import { commerceScopeFromScope } from "./theme-gate.mjs";
51
54
 
52
55
  export const RECORD_STAGES = Object.freeze(["setup", "build", "polish", "theme", "deploy"]);
53
56
 
54
- // Every flag `record` reads, plus the two any command accepts (run id and
55
- // lifecycle journal). Anything else is refused before a file is read.
56
- const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal"]);
57
+ // Every flag `record` reads, plus the three any command accepts (run id,
58
+ // lifecycle journal, and the deviation reason the deviation notice asks
59
+ // agents to declare). Anything else is refused before a file is read.
60
+ const RECORD_FLAGS = Object.freeze(["packet", "context", "report", "dry-run", "json", "run-id", "lifecycle-journal", "deviation-reason"]);
57
61
  const POLISH_RECORD_FLAGS = Object.freeze(["evidence"]);
58
62
  const DEPLOY_RECORD_FLAGS = Object.freeze(["base-url"]);
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"]);
69
+ // The page-kit environment the built output was rendered in, recorded on
70
+ // stages.assembly.evidence.build_environment (local proof mode builds in
71
+ // development; doctor and page-kit parity read it).
72
+ export const BUILD_ENVIRONMENTS = Object.freeze([LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT]);
59
73
 
60
74
  // The keys a --evidence file may carry. `evidence` is stages.polish.evidence;
61
75
  // `repair_loop_defect` is report.theme.repair_loop_defect; `blockers` (status
@@ -74,6 +88,13 @@ function schemaValidator(file) {
74
88
  return validators.get(file);
75
89
  }
76
90
 
91
+ // The value at an Ajv instancePath (JSON Pointer) in the validated document.
92
+ function valueAt(document, pointer) {
93
+ return pointer.split("/").slice(1)
94
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
95
+ .reduce((node, key) => (node == null ? undefined : node[key]), document);
96
+ }
97
+
77
98
  // Ajv's instancePath (`/theme/repair_loop_defect`) as the dotted field name
78
99
  // the rest of the toolkit prints (`theme.repair_loop_defect`).
79
100
  function schemaProblems(file, value, label) {
@@ -83,7 +104,11 @@ function schemaProblems(file, value, label) {
83
104
  const problems = [];
84
105
  for (const error of validate.errors || []) {
85
106
  const field = error.instancePath.split("/").filter(Boolean).join(".") || "(root)";
86
- const line = `${label} ${field} ${error.message}`;
107
+ // Ajv's enum message names no values; the allowed list is the remedy.
108
+ const allowed = error.keyword === "enum" && Array.isArray(error.params?.allowedValues)
109
+ ? `: ${error.params.allowedValues.map((allowedValue) => JSON.stringify(allowedValue)).join(", ")} (got ${JSON.stringify(valueAt(value, error.instancePath))})`
110
+ : "";
111
+ const line = `${label} ${field} ${error.message}${allowed}`;
87
112
  if (seen.has(line)) continue;
88
113
  seen.add(line);
89
114
  problems.push(line);
@@ -96,33 +121,45 @@ function typeName(value) {
96
121
  return Array.isArray(value) ? "array" : typeof value;
97
122
  }
98
123
 
99
- function refuseRecord(stage, problems) {
124
+ // visual_review keys only `polish capture` writes. `record polish --evidence`
125
+ // refuses them by name and carries the captured values forward.
126
+ export const PACKAGE_OWNED_VISUAL_REVIEW_KEYS = Object.freeze(["page_load", "media_weight", "readability"]);
127
+ export const PACKAGE_OWNED_KEY_REFUSAL = "package_owned_key";
128
+
129
+ export function refuseRecord(stage, problems) {
100
130
  return new Error(`record ${stage} refused; nothing was written:\n${problems.map((problem) => `- ${problem}`).join("\n")}`);
101
131
  }
102
132
 
103
133
  export function parseRecordArgs(args) {
104
134
  const stage = args._[1];
105
- if (!RECORD_STAGES.includes(stage) || args._.length !== 2) {
106
- 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>, and record deploy --base-url <served url>.`);
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>].`);
107
137
  }
108
- const known = new Set([...RECORD_FLAGS, ...(stage === "polish" ? POLISH_RECORD_FLAGS : []), ...(stage === "deploy" ? DEPLOY_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 : [])]);
109
139
  const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
110
140
  if (unknown.length) {
111
141
  throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
112
142
  }
113
- for (const flag of ["context", "report", "run-id", "lifecycle-journal"]) {
143
+ for (const flag of ["context", "report", "run-id", "lifecycle-journal", "deviation-reason", "brief"]) {
114
144
  if (Object.hasOwn(args, flag)) requireArg(args, flag);
115
145
  }
116
146
  if (Object.hasOwn(args, "dry-run") && args["dry-run"] !== true) {
117
147
  throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
118
148
  }
119
149
  if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
150
+ const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
151
+ if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
152
+ throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
153
+ }
120
154
  return {
121
155
  stage,
122
156
  packetPath: resolve(requireArg(args, "packet")),
123
157
  evidencePath: stage === "polish" ? resolve(requireArg(args, "evidence")) : null,
124
158
  baseUrl: stage === "deploy" ? requireArg(args, "base-url") : null,
125
159
  dryRun: args["dry-run"] === true,
160
+ buildEnvironment,
161
+ briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
162
+ deviationReason: optionalString(args["deviation-reason"]),
126
163
  };
127
164
  }
128
165
 
@@ -181,8 +218,13 @@ export function readPolishEvidenceFile(path) {
181
218
  }
182
219
  if (evidence.visual_review !== undefined && !isObject(evidence.visual_review)) {
183
220
  problems.push(`evidence.visual_review must be an object with a screenshots array (got ${typeName(evidence.visual_review)}).`);
184
- } else if (isObject(evidence.visual_review) && Object.hasOwn(evidence.visual_review, "page_load")) {
185
- problems.push(`evidence.visual_review.page_load is written only by ${cmd("polish")} capture; remove it from the file (the captured value on the report is kept).`);
221
+ } else if (isObject(evidence.visual_review)) {
222
+ // Package-owned keys are refused by name and listed first, so the
223
+ // refusal leads with its code.
224
+ const owned = PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(evidence.visual_review, key));
225
+ if (owned.length) {
226
+ problems.unshift(`${PACKAGE_OWNED_KEY_REFUSAL}: ${owned.map((key) => `evidence.visual_review.${key}`).join(" and ")} ${owned.length === 1 ? "is" : "are"} written only by ${cmd("polish")} capture; remove ${owned.length === 1 ? "it" : "them"} from the file (the captured value on the report is kept).`);
227
+ }
186
228
  }
187
229
  }
188
230
  if (Object.hasOwn(input, "repair_loop_defect") && input.repair_loop_defect !== null && !isObject(input.repair_loop_defect)) {
@@ -235,28 +277,72 @@ function composeSetup(report, context, { now, recordedBy }) {
235
277
  return { report: nextReport, context: nextContext };
236
278
  }
237
279
 
238
- function composeBuild(report, { now, recordedBy, fingerprint }) {
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 }) {
239
295
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
240
- const assembly = {
241
- ...withoutKeys(stageObject(report, "assembly"), ["source_package_material_fingerprint"]),
296
+ const previousAssembly = stageObject(report, "assembly");
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]),
304
+ ...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
242
305
  stage: "assembly",
243
306
  status: "completed",
244
307
  build_fingerprint: fingerprint,
245
308
  ...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
309
+ ...stamps,
246
310
  completed_at: now,
247
311
  recorded_by: recordedBy,
248
312
  blockers: [],
249
313
  };
250
- // Polish evidence bound to this exact output stays; anything else is owed
251
- // again. The evidence object is kept so `polish capture` has somewhere to
252
- // 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
+ }
253
339
  const previousPolish = stageObject(report, "polish");
254
340
  const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
255
341
  && optionalString(previousPolish.source_build_fingerprint) === fingerprint;
256
342
  const polish = polishStillCurrent
257
343
  ? previousPolish
258
344
  : {
259
- ...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]),
260
346
  stage: "polish",
261
347
  status: "required",
262
348
  required_by: "build",
@@ -265,8 +351,11 @@ function composeBuild(report, { now, recordedBy, fingerprint }) {
265
351
  return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
266
352
  }
267
353
 
268
- function composePolish(report, { now, recordedBy, fingerprint, input }) {
354
+ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
269
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;
270
359
  const previousVisual = isObject(previous.evidence?.visual_review) ? previous.evidence.visual_review : {};
271
360
  // A blocked or skipped record given no evidence keeps what is there (the
272
361
  // capture's bounded evidence stays for diagnosis).
@@ -275,18 +364,21 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
275
364
  ...input.evidence,
276
365
  visual_review: {
277
366
  ...input.evidence.visual_review,
278
- ...(Object.hasOwn(previousVisual, "page_load") ? { page_load: previousVisual.page_load } : {}),
367
+ ...Object.fromEntries(PACKAGE_OWNED_VISUAL_REVIEW_KEYS.filter((key) => Object.hasOwn(previousVisual, key)).map((key) => [key, previousVisual[key]])),
279
368
  },
280
369
  }
281
370
  : previous.evidence;
282
371
  const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
283
- const polish = {
284
- ...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]),
285
374
  stage: "polish",
286
375
  status: input.status,
287
376
  performed_by: POLISH_PRODUCER,
288
377
  source_build_fingerprint: fingerprint,
289
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) : {}),
290
382
  // A blocked Polish has not completed.
291
383
  ...(input.status === "blocked" ? {} : { completed_at: now }),
292
384
  recorded_by: recordedBy,
@@ -294,6 +386,15 @@ function composePolish(report, { now, recordedBy, fingerprint, input }) {
294
386
  ...(input.skipReason ? { skip_reason: input.skipReason } : {}),
295
387
  blockers: input.blockers,
296
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" });
297
398
  const nextReport = { ...report, stages: { ...report.stages, polish } };
298
399
  // A null defect on a report with no theme block says nothing to record.
299
400
  if (input.hasRepairLoopDefect && (isObject(report.theme) || input.repairLoopDefect !== null)) {
@@ -580,10 +681,17 @@ function ladderProblems(stage, doctor, report) {
580
681
  if (gate) return [`next answers prepare-build: ${gate.reason}`];
581
682
  const problems = [];
582
683
  for (const earlier of NEXT_STAGE_ORDER.slice(0, NEXT_STAGE_ORDER.indexOf(stage))) {
684
+ // The rule next's stage picker reads: on the local preview a missing polish
685
+ // is carried forward (local-preview-policy), and next moves on to deploy.
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.
583
689
  const key = reportKeyForCliStage(earlier);
584
690
  const status = String(report.stages[key]?.status || "");
585
- if (!stageIsTerminal(status)) {
586
- 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.`);
587
695
  }
588
696
  }
589
697
  return problems;
@@ -671,7 +779,24 @@ function deployFacts(doctor, packet, probe) {
671
779
  * records through recordStageCommand.
672
780
  */
673
781
  export async function recordCommand(args, options = {}) {
674
- 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
+ }
675
800
  const probe = stage === "deploy" && existsSync(packetPath)
676
801
  ? await probeLocalPreview({ packetPath, baseUrl, ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}) })
677
802
  : null;
@@ -697,7 +822,7 @@ function readPacketFile(stage, packetPath) {
697
822
  * composed or written.
698
823
  */
699
824
  export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
700
- const { stage, packetPath, evidencePath, dryRun } = parseRecordArgs(args);
825
+ const { stage, packetPath, evidencePath, dryRun, buildEnvironment, deviationReason } = parseRecordArgs(args);
701
826
  if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
702
827
  if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
703
828
  // Operator input, not target state: no campaigns-os writer produces it.
@@ -718,7 +843,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
718
843
  // A dry run writes nothing, so it takes no lock and creates no lock files
719
844
  // (the commitAssemblyReport preview convention); it reads in the same order.
720
845
  const run = () => recordUnderLock({
721
- stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead,
846
+ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead, buildEnvironment, deviationReason,
722
847
  });
723
848
  const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
724
849
  const { composed, facts, layer, reportPath, contextPath, after } = recorded;
@@ -735,6 +860,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
735
860
  ] : [
736
861
  `stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
737
862
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
863
+ ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
738
864
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
739
865
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
740
866
  ];
@@ -767,7 +893,13 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
767
893
  // re-check, and the post-write doctor read for `next_stage`. No campaigns-os
768
894
  // writer can rebind, rewrite or republish any of them between the read and
769
895
  // the write.
770
- function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dryRun, timestamp, recordedBy, afterDoctorRead }) {
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 }) {
771
903
  // The same workspace `next` resolves, so the record lands in the report
772
904
  // `next` reads now, not the one it read before the lock was free.
773
905
  const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
@@ -802,13 +934,13 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
802
934
  const next = stage === "setup"
803
935
  ? composeSetup(report, context, { now: timestamp, recordedBy })
804
936
  : stage === "build"
805
- ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint })
937
+ ? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, inputs: recordInputs(doctor), deviationReason })
806
938
  : stage === "theme"
807
939
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
808
940
  : stage === "deploy"
809
941
  ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
810
- : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input });
811
- applyDerivedAssemblyReportSummary(next.report);
942
+ : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
943
+ applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
812
944
  // The packet's one new value, deploy.preview_url, is checked by
813
945
  // localPreviewUrl; the rest of the packet is as the operator left it.
814
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") : "";