@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
@@ -0,0 +1,708 @@
1
+ // `campaigns-os record brief`: save Campaign Build Brief answers without
2
+ // re-running intake. `campaigns-os record spec`: bind the CampaignSpec as it
3
+ // is now without re-running intake.
4
+ //
5
+ // The brief file is re-normalized and re-evaluated with the intake products
6
+ // the Build Context recorded, through the same artifact function intake
7
+ // calls. A save whose material content is unchanged keeps every stage status
8
+ // and its evidence; a material change makes the stages the input dependency
9
+ // map names owed again (status `required` with input_change), each superseded
10
+ // record going whole into its stage's history first. `record spec` rebinds
11
+ // the Build Context's and the Assembly Report's spec identity, re-derives the
12
+ // brief from the brief file last saved by intake or record brief (never by
13
+ // discovery), and makes build, Polish and QA owed again unless a stage was
14
+ // already recorded against the new content; waivers, accepts, the theme,
15
+ // the packet and the setup and deploy records are never touched. Refusals
16
+ // are checked in a fixed order before anything is written; --dry-run runs
17
+ // every check and writes nothing.
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
20
+ import { dirname, join, relative, resolve } from "node:path";
21
+
22
+ import {
23
+ BUILD_BRIEF_CANDIDATE_FILENAMES,
24
+ briefMaterialFingerprint,
25
+ createCampaignBuildBriefArtifact,
26
+ loadCampaignBuildBriefFile,
27
+ } from "./build-brief.mjs";
28
+ import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
29
+ import { isObject, optionalString, relFromDir, resolveFromFile } from "./cli-helpers.mjs";
30
+ import { writeJsonAtomic } from "./doctor-sidecar.mjs";
31
+ import { activeSpecPages, specIdentityMismatch } from "./doctor/checks.mjs";
32
+ import {
33
+ INPUT_DEPENDENCY_MAP,
34
+ STAMPED_STAGES,
35
+ absentBriefMaterial,
36
+ assessInputCurrency,
37
+ briefFileUnusable,
38
+ briefIntakeBindings,
39
+ changeReason,
40
+ currentBriefMaterial,
41
+ currentSpecMaterial,
42
+ wellFormedBriefMaterial,
43
+ } from "./input-currency.mjs";
44
+ import { cmd } from "./install-invocation.mjs";
45
+ import { publicRouteForPage } from "./source-html-intake.mjs";
46
+ import { specHashesMatch } from "./spec-identity.mjs";
47
+ import { archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
48
+ import { refuseRecord } from "./stage-record.mjs";
49
+ import { withTargetLockSync } from "./target-lock.mjs";
50
+
51
+ // A brief file over this size, or with more design_authority entries than
52
+ // this, is refused before anything is read further.
53
+ const BRIEF_MAX_BYTES = 1024 * 1024;
54
+ const BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES = 512;
55
+ const BRIEF_PROMPT_PREFIX = "BUILD_BRIEF_";
56
+ const BRIEF_GATE_PREFIX = "build_brief.";
57
+ const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
58
+
59
+ const isBriefBlocker = (entry) => typeof entry?.code === "string" && (entry.code.startsWith(BRIEF_PROMPT_PREFIX) || entry.code.startsWith(BRIEF_GATE_PREFIX));
60
+
61
+ function toConstantCase(value) {
62
+ return String(value || "").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
63
+ }
64
+
65
+ function relFromFile(filePath, targetPath) {
66
+ const rel = relative(dirname(resolve(filePath)), resolve(targetPath));
67
+ if (!rel) return ".";
68
+ return rel.startsWith(".") ? rel : `./${rel}`;
69
+ }
70
+
71
+ function readJsonOrNull(path) {
72
+ try {
73
+ return path && existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The prepare_build status the stage's remaining blockers give: blocked with
81
+ * any blocker, completed_partial when pages are declared out of source scope,
82
+ * completed otherwise.
83
+ */
84
+ export function prepareBuildStageStatus({ blockers = [], declaredScopeSkips = [] } = {}) {
85
+ if (blockers.length) return "blocked";
86
+ return declaredScopeSkips.length ? "completed_partial" : "completed";
87
+ }
88
+
89
+ /**
90
+ * The brief's prepare_build blockers, as intake derives them from a brief
91
+ * result: every blocking gate, and each open question when the brief is
92
+ * prepared.
93
+ */
94
+ export function briefPrepareBuildBlockers(buildBrief) {
95
+ return [
96
+ ...buildBrief.blockers.map((gate) => ({ code: gate.code, stage: "prepare_build", message: gate.message, field: gate.field || null })),
97
+ ...(buildBrief.mode === "prepared"
98
+ ? buildBrief.questions.map((question) => ({ code: `${BRIEF_PROMPT_PREFIX}${toConstantCase(question.id)}`, stage: "prepare_build", message: question.question, field: question.field }))
99
+ : []),
100
+ ];
101
+ }
102
+
103
+ // The questions a guided brief asks the operator, as intake writes them to
104
+ // context.prompts_required.
105
+ function briefPrompts(buildBrief) {
106
+ if (buildBrief.mode === "prepared") return [];
107
+ return buildBrief.questions.map((question) => ({
108
+ code: `${BRIEF_PROMPT_PREFIX}${toConstantCase(question.id)}`,
109
+ stage: "prepare_build",
110
+ message: question.question,
111
+ detail: { field: question.field, reason: question.reason, options: question.options, blocking: question.blocking },
112
+ }));
113
+ }
114
+
115
+ /**
116
+ * prepare_build with its brief blockers replaced by `briefBlockers` and every
117
+ * other blocker kept; the status follows from what remains.
118
+ */
119
+ export function prepareBuildBriefBlockers(stage, briefBlockers) {
120
+ const current = isObject(stage) ? stage : { stage: "prepare_build" };
121
+ const kept = (Array.isArray(current.blockers) ? current.blockers : []).filter((blocker) => !isBriefBlocker(blocker));
122
+ const blockers = [...kept, ...briefBlockers];
123
+ const declaredScopeSkips = Array.isArray(current.declared_out_of_scope) ? current.declared_out_of_scope : [];
124
+ return { ...current, status: prepareBuildStageStatus({ blockers, declaredScopeSkips }), blockers };
125
+ }
126
+
127
+ /**
128
+ * Apply the dependency map for the changed inputs (`causes`, any of
129
+ * "presentation", "qa_policy", "spec"): on each stage the map makes owed whose
130
+ * raw status is not skipped, write input_change; a completed record also goes
131
+ * whole into history and is demoted to `required`, keeping only what
132
+ * describes the output (assembly's build_fingerprint, Polish's evidence).
133
+ * `only` limits the stages touched (record build writes assembly itself).
134
+ * Returns the report copy and the stages it demoted.
135
+ */
136
+ export function demoteStages(report, { causes, now, by, only = null }) {
137
+ const reason = changeReason(causes);
138
+ const owed = new Map();
139
+ for (const cause of causes) {
140
+ for (const [key, requirement] of Object.entries(INPUT_DEPENDENCY_MAP[cause]?.stages || {})) {
141
+ if (!owed.has(key)) owed.set(key, { ...requirement, required_for: [...requirement.required_for] });
142
+ }
143
+ }
144
+ // A combined change names the winning cause's requirement.
145
+ const winner = ["spec", "presentation", "qa_policy"].find((cause) => causes.includes(cause));
146
+ const stages = { ...(isObject(report?.stages) ? report.stages : {}) };
147
+ const demoted = [];
148
+ for (const key of STAMPED_STAGES) {
149
+ if (!owed.has(key) || (only && !only.includes(key))) continue;
150
+ const previous = isObject(stages[key]) ? stages[key] : { stage: key };
151
+ if (previous.status === "skipped") continue;
152
+ const requirement = INPUT_DEPENDENCY_MAP[winner]?.stages?.[key] || owed.get(key);
153
+ let next = { ...previous, input_change: inputChangeFor(key, previous, { at: now, reason }) };
154
+ if (COMPLETED_STATUSES.includes(previous.status)) {
155
+ next = archiveSupersededRecord(next, previous, { by, reason, at: now });
156
+ for (const field of ["completed_at", "recorded_by", "performed_by", "unchanged_output_reason", "source_brief_material", "source_spec_material_hash"]) delete next[field];
157
+ if (key === "qa") for (const field of ["verdict_run_id", "evidence", "purchase_proof"]) delete next[field];
158
+ next.status = "required";
159
+ next.required_by = requirement.required_by;
160
+ next.required_for = [...requirement.required_for];
161
+ demoted.push(key);
162
+ }
163
+ stages[key] = next;
164
+ }
165
+ return { report: { ...report, stages }, demoted };
166
+ }
167
+
168
+ /**
169
+ * The previous report's brief mode (when it is this campaign's report) and
170
+ * the previous normalized brief, read before an intake or save replaces them.
171
+ */
172
+ export function priorBriefInputsForIntake({ reportPath, briefPath, identity }) {
173
+ const report = readJsonOrNull(reportPath);
174
+ const boundReportMode = report && assemblyReportMatchesPacket(report, identity) ? optionalString(report.build_brief?.mode) : null;
175
+ const previous = readJsonOrNull(briefPath);
176
+ return { boundReportMode, previousNormalizedBrief: isObject(previous) ? previous : null };
177
+ }
178
+
179
+ function fileSha256(path) {
180
+ return `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`;
181
+ }
182
+
183
+ function realpathOrNull(path) {
184
+ try {
185
+ return realpathSync(path);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ // The brief file candidates: an explicit --brief, or the first candidate name
192
+ // found in the source root and in the target (intake's discovery order).
193
+ function briefCandidates(explicitPath, { sourceRoot, targetRepo }) {
194
+ if (explicitPath) return { explicit: true, files: [{ path: explicitPath, source: "operator_flag" }] };
195
+ const files = [];
196
+ for (const [root, source] of [[sourceRoot, "source_root"], [targetRepo, "target_repo"]]) {
197
+ if (!root) continue;
198
+ const found = BUILD_BRIEF_CANDIDATE_FILENAMES.map((name) => join(root, name)).find((candidate) => {
199
+ try {
200
+ return statSync(candidate).isFile();
201
+ } catch {
202
+ return false;
203
+ }
204
+ });
205
+ if (found && !files.some((file) => realpathOrNull(file.path) === realpathOrNull(found))) files.push({ path: found, source });
206
+ }
207
+ return { explicit: false, files };
208
+ }
209
+
210
+ function briefTooLarge(path) {
211
+ if (statSync(path).size > BRIEF_MAX_BYTES) return `${path} is ${statSync(path).size} bytes; the limit is ${BRIEF_MAX_BYTES} bytes (1 MiB).`;
212
+ let value;
213
+ try {
214
+ value = loadCampaignBuildBriefFile(path).value;
215
+ } catch {
216
+ return null;
217
+ }
218
+ const entries = isObject(value?.design_authority) ? Object.keys(value.design_authority).length : 0;
219
+ return entries > BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES ? `${path} has ${entries} design_authority entries; the limit is ${BRIEF_MAX_DESIGN_AUTHORITY_ENTRIES}.` : null;
220
+ }
221
+
222
+ const sameActivePages = (a, b) => a.length === b.length && a.every((page, index) => page.id === b[index]?.id && (page.page_url ?? null) === (b[index]?.page_url ?? null));
223
+
224
+ // True when the CampaignSpec's active pages (ids, order or routes) differ
225
+ // from the ones intake recorded in the Build Context (or none are recorded).
226
+ function pageScopeChanged(spec, context) {
227
+ const recorded = Array.isArray(context?.spec?.active_pages) ? context.spec.active_pages : null;
228
+ const derived = activeSpecPages(spec).map((page) => ({ id: page.id, page_url: publicRouteForPage(page) }));
229
+ return !recorded || !sameActivePages(derived, recorded);
230
+ }
231
+
232
+ // The files Campaigns OS writes itself, which a brief path must never name.
233
+ function packageArtifactPaths(workspace, normalizedPath) {
234
+ return [
235
+ { label: "normalized brief", path: normalizedPath },
236
+ { label: "Build Packet", path: workspace.packetPath },
237
+ { label: "Build Context", path: workspace.contextPath },
238
+ { label: "Assembly Report", path: workspace.reportPath },
239
+ { label: "doctor sidecar", path: workspace.doctorOutPath },
240
+ ].filter((entry) => entry.path);
241
+ }
242
+
243
+ function isRegularFile(path) {
244
+ try {
245
+ return statSync(path).isFile();
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ // The brief-file refusals both refreshes share, in their fixed order:
252
+ // too large, a package-owned artifact, then no readable file. Size and
253
+ // realpath are read from regular files only. `missingDetail(file)` words the
254
+ // last refusal: `file` is null when there is no candidate at all, otherwise
255
+ // the candidate with `why` it cannot be read. Returns [code, detail] or null.
256
+ function briefFileRefusal(files, packageArtifacts, missingDetail) {
257
+ const regular = files.filter((file) => isRegularFile(file.path));
258
+ for (const file of regular) {
259
+ const large = briefTooLarge(file.path);
260
+ if (large) return ["brief_too_large", large];
261
+ }
262
+ for (const file of regular) {
263
+ const real = realpathOrNull(file.path);
264
+ const artifact = packageArtifacts.find((entry) => real && realpathOrNull(entry.path) === real);
265
+ if (artifact) return ["brief_source_is_package_artifact", `${file.path} is the ${artifact.label}, which Campaigns OS writes itself; save the brief from a campaign-build-brief.json (or .yaml) file of your own.`];
266
+ }
267
+ if (!files.length) return ["brief_file_missing", missingDetail(null)];
268
+ for (const file of files) {
269
+ const why = briefFileUnusable(file.path);
270
+ if (why) return ["brief_file_missing", missingDetail({ ...file, why })];
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // Every record brief refusal, in the fixed order; the first that holds is
276
+ // returned as [code, detail], or null when none holds.
277
+ function briefRefusal({ candidates, packageArtifacts, context, spec, report }) {
278
+ const fileRefusal = briefFileRefusal(candidates.files, packageArtifacts, (file) => (file
279
+ ? `the ${candidates.explicit ? "--brief file" : "brief file found at"} ${file.path} ${file.why}; save a readable brief file (or pass --brief <file>).`
280
+ : `no ${BUILD_BRIEF_CANDIDATE_FILENAMES.join(", ")} in the source root or the target; copy the normalized draft to campaign-build-brief.json in the target, set the open fields, and run record brief again (or pass --brief <file>).`));
281
+ if (fileRefusal) return fileRefusal;
282
+ if (!candidates.explicit && candidates.files.length > 1) {
283
+ const [first, second] = candidates.files;
284
+ if (!readFileSync(first.path).equals(readFileSync(second.path))) {
285
+ return ["brief_path_ambiguous", `${first.path} and ${second.path} differ; pass --brief <file> to name the one to save.`];
286
+ }
287
+ }
288
+ const missing = briefInputsMissing(context);
289
+ if (missing.length) return ["brief_inputs_unavailable", `the Build Context lacks ${missing.join(", ")}, which evaluating the brief needs; re-run intake to restore it.`];
290
+ const bound = optionalString(report?.identity?.spec_material_hash);
291
+ const current = currentSpecMaterial(spec);
292
+ if (!current || !bound || current !== `sha256:${bound.replace(/^sha256:/, "")}`) {
293
+ return ["spec_changed_run_record_spec", `the CampaignSpec material (${current || "unreadable"}) differs from the one the Assembly Report binds (${bound || "none"}); save the spec change with ${cmd("record")} spec first.`];
294
+ }
295
+ if (pageScopeChanged(spec, context)) {
296
+ return ["page_scope_changed", "the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages; a page-scope change needs intake."];
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // The intake products evaluating a brief needs that the Build Context lacks.
302
+ function briefInputsMissing(context) {
303
+ return [
304
+ ["spec.active_pages", Array.isArray(context?.spec?.active_pages)],
305
+ ["page_map", Array.isArray(context?.page_map)],
306
+ ["source.asset_crawl", context?.source?.asset_crawl != null],
307
+ ["commerce_zone_findings", Array.isArray(context?.commerce_zone_findings)],
308
+ ].filter(([, present]) => !present).map(([field]) => field);
309
+ }
310
+
311
+ const questionIds = (brief) => (Array.isArray(brief?.questions) ? brief.questions.map((question) => question?.id) : []);
312
+ const gateCodes = (brief) => (Array.isArray(brief?.gates) ? brief.gates.map((gate) => gate?.code) : []);
313
+
314
+ /**
315
+ * The brief re-evaluated as intake evaluates it: `briefFile` ({path, source},
316
+ * or null for the generated guided draft) through the same artifact function,
317
+ * with the Build Context's intake products (page_map's source_path read as
318
+ * the draft's path), the report's bound mode and the previous normalized
319
+ * brief. Returns the brief result and its bindings.
320
+ */
321
+ function rederiveBrief({ briefFile, spec, context, packet, report, normalizedPath }) {
322
+ const previousNormalizedBrief = readJsonOrNull(normalizedPath);
323
+ const buildBrief = createCampaignBuildBriefArtifact({
324
+ inputPath: briefFile?.path || null,
325
+ inputSource: briefFile?.source || null,
326
+ spec,
327
+ activePages: activeSpecPages(spec),
328
+ pageMappings: context.page_map.map((entry) => ({ page_id: entry.page_id, path: entry.source_path || null, skip_reason: entry.skip_reason || null })),
329
+ templateFamily: context.template?.family || packet.assembly?.template_family || null,
330
+ sourceAssetCrawl: context.source.asset_crawl,
331
+ commerceZoneFindings: context.commerce_zone_findings,
332
+ boundReportMode: assemblyReportMatchesPacket(report, packet) ? optionalString(report.build_brief?.mode) : null,
333
+ previousNormalizedBrief: isObject(previousNormalizedBrief) ? previousNormalizedBrief : null,
334
+ });
335
+ if (buildBrief.inputPath) buildBrief.artifact._meta.input_path = relFromFile(normalizedPath, buildBrief.inputPath);
336
+ return { buildBrief, bindings: briefIntakeBindings(buildBrief) };
337
+ }
338
+
339
+ /**
340
+ * How a re-evaluated brief compares with what the report binds: `unchanged`
341
+ * when the material, mode, status, question ids, gate codes and file sha256
342
+ * all match; `saved_with_invalidation` (with the changed partitions as
343
+ * `causes`) when a material partition differs; `saved` otherwise, including
344
+ * when no well-formed material is bound yet.
345
+ */
346
+ function briefSaveOutcome(report, buildBrief, bindings) {
347
+ const material = bindings.material;
348
+ const bound = report.build_brief?.material;
349
+ // Bound material not of its closed shape is treated as not bound: the save
350
+ // rebinds it, and each stage still reads by its own stamps.
351
+ const boundPresent = wellFormedBriefMaterial(bound);
352
+ const causes = boundPresent ? ["presentation", "qa_policy"].filter((partition) => bound[partition] !== material[partition]) : [];
353
+ const unchanged = boundPresent && !causes.length
354
+ && report.build_brief?.mode === buildBrief.mode
355
+ && report.build_brief?.status === buildBrief.artifact.status
356
+ && JSON.stringify(questionIds(report.build_brief)) === JSON.stringify(questionIds(buildBrief.artifact))
357
+ && JSON.stringify(gateCodes(report.build_brief)) === JSON.stringify(gateCodes(buildBrief.artifact))
358
+ && report.build_brief?.input_sha256 === bindings.input_sha256;
359
+ return { outcome: unchanged ? "unchanged" : causes.length ? "saved_with_invalidation" : "saved", causes };
360
+ }
361
+
362
+ // The Build Context and Assembly Report a brief save writes: the brief
363
+ // summary and bindings on both, the brief's prompts on the context, and
364
+ // prepare_build re-derived from the brief's blockers. No other stage field.
365
+ // The saved file becomes the brief source a later refresh reads: the
366
+ // context's intake.brief_path names it when it was given with --brief, and
367
+ // is cleared when it was found by discovery, as intake records it.
368
+ function briefSaveArtifacts({ context, report, buildBrief, bindings, targetRepo }) {
369
+ const briefSummary = {
370
+ ...(isObject(context.build_brief) ? context.build_brief : {}),
371
+ mode: buildBrief.mode,
372
+ status: buildBrief.artifact.status,
373
+ input_path: buildBrief.inputPath ? relFromDir(targetRepo, buildBrief.inputPath) : null,
374
+ question_count: buildBrief.questions.length,
375
+ gate_count: buildBrief.gates.length,
376
+ questions: buildBrief.questions,
377
+ gates: buildBrief.gates,
378
+ ...bindings,
379
+ };
380
+ const explicitSource = buildBrief.inputPath && buildBrief.artifact._meta?.input_source === "operator_flag";
381
+ const nextContext = {
382
+ ...context,
383
+ ...(isObject(context.intake) ? { intake: { ...context.intake, brief_path: explicitSource ? relFromDir(targetRepo, buildBrief.inputPath) : null } } : {}),
384
+ build_brief: briefSummary,
385
+ prompts_required: [
386
+ ...(Array.isArray(context.prompts_required) ? context.prompts_required : []).filter((prompt) => !isBriefBlocker(prompt)),
387
+ ...briefPrompts(buildBrief),
388
+ ],
389
+ };
390
+ const nextReport = {
391
+ ...report,
392
+ build_brief: JSON.parse(JSON.stringify(briefSummary)),
393
+ stages: { ...report.stages, prepare_build: prepareBuildBriefBlockers(report.stages.prepare_build, briefPrepareBuildBlockers(buildBrief)) },
394
+ };
395
+ return { nextContext, nextReport };
396
+ }
397
+
398
+ // The binding_unknown notice: each recorded stage whose inputs cannot be
399
+ // confirmed, named to be recorded again.
400
+ function bindingUnknownNotices(currency) {
401
+ const unknownStages = STAMPED_STAGES.filter((key) => currency.stages[key] === "unknown");
402
+ return unknownStages.length
403
+ ? [{ code: "binding_unknown", stages: unknownStages, message: `Recorded ${unknownStages.join(", ")} do not say which brief and CampaignSpec content they were made against; record ${unknownStages.join(", ")} again.` }]
404
+ : [];
405
+ }
406
+
407
+ // One refresh run under the target lock (a dry run takes none: it writes nothing).
408
+ function underTargetLock(packetPath, command, dryRun, now, run) {
409
+ if (!existsSync(packetPath)) throw new Error(`${command}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
410
+ const lockedTarget = targetRepoFor(packetPath, JSON.parse(readFileSync(packetPath, "utf8")));
411
+ const timestamp = now().toISOString();
412
+ const locked = () => run({ lockedTarget, timestamp });
413
+ return dryRun ? locked() : withTargetLockSync(lockedTarget, locked, { command });
414
+ }
415
+
416
+ /**
417
+ * Run one `record brief` invocation. Returns the result object the CLI
418
+ * prints; throws a refusal (nothing written) when a check fails.
419
+ */
420
+ export function recordBrief({ packetPath, briefPath: explicitBrief = null, contextPath = undefined, reportPath = undefined, dryRun = false }, { now = () => new Date() } = {}) {
421
+ return underTargetLock(packetPath, "record brief", dryRun, now, ({ lockedTarget, timestamp }) => saveBriefUnderLock({
422
+ packetPath, explicitBrief, sidecars: { contextPath, reportPath }, lockedTarget, dryRun, timestamp,
423
+ }));
424
+ }
425
+
426
+ function saveBriefUnderLock({ packetPath, explicitBrief, sidecars, lockedTarget, dryRun, timestamp }) {
427
+ const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
428
+ const { packet, contextPath, reportPath, targetRepo } = workspace;
429
+ if (targetRepo !== lockedTarget) throw refuseRecord("brief", [`The Build Packet was retargeted while this save waited for the target lock (${lockedTarget} is now ${targetRepo}); run ${cmd("record")} brief again.`]);
430
+ if (!isObject(packet.build_brief)) throw refuseRecord("brief", [`The Build Packet records no Campaign Build Brief; run ${cmd("start")} or ${cmd("prepare-build")} to create one.`]);
431
+ const report = readJsonOrNull(reportPath);
432
+ if (!isObject(report?.stages)) throw new Error(`record brief: no readable Assembly Report at ${reportPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
433
+ const context = readJsonOrNull(contextPath);
434
+ const normalizedPath = resolveFromFile(packetPath, packet.build_brief.normalized_path);
435
+ const specPath = optionalString(packet.spec?.local_path) ? resolveFromFile(packetPath, packet.spec.local_path) : null;
436
+ const spec = readJsonOrNull(specPath);
437
+ const sourceRoot = optionalString(packet.source_html?.root) ? resolveFromFile(packetPath, packet.source_html.root) : null;
438
+ const candidates = briefCandidates(explicitBrief ? resolve(explicitBrief) : null, { sourceRoot, targetRepo });
439
+ const refusal = briefRefusal({ candidates, packageArtifacts: packageArtifactPaths(workspace, normalizedPath), context, spec, report });
440
+ if (refusal) throw refuseRecord("brief", [`${refusal[0]}: ${refusal[1]}`]);
441
+
442
+ const { buildBrief, bindings } = rederiveBrief({ briefFile: candidates.files[0], spec, context, packet, report, normalizedPath });
443
+ const material = bindings.material;
444
+ const { outcome, causes } = briefSaveOutcome(report, buildBrief, bindings);
445
+ const currency = assessInputCurrency({ report, briefMaterial: material, specMaterial: currentSpecMaterial(spec) });
446
+ const notices = bindingUnknownNotices(currency);
447
+
448
+ let demoted = [];
449
+ const writes = [];
450
+ if (outcome !== "unchanged") {
451
+ const nextPacket = {
452
+ ...packet,
453
+ build_brief: {
454
+ ...packet.build_brief,
455
+ mode: buildBrief.mode,
456
+ status: buildBrief.artifact.status,
457
+ input_path: relFromFile(packetPath, buildBrief.inputPath),
458
+ question_count: buildBrief.questions.length,
459
+ gate_count: buildBrief.gates.length,
460
+ ...bindings,
461
+ },
462
+ };
463
+ const saved = briefSaveArtifacts({ context, report, buildBrief, bindings, targetRepo });
464
+ let nextReport = saved.nextReport;
465
+ if (outcome === "saved_with_invalidation") {
466
+ const applied = demoteStages(nextReport, { causes, now: timestamp, by: "record brief" });
467
+ nextReport = applied.report;
468
+ demoted = applied.demoted;
469
+ nextReport.evidence = [
470
+ ...(Array.isArray(report.evidence) ? report.evidence : []),
471
+ `${timestamp} record brief: brief ${causes.join(" and ")} changed; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"} (${changeReason(causes)}).`,
472
+ ];
473
+ }
474
+ writes.push(packetPath, normalizedPath, contextPath, reportPath);
475
+ if (!dryRun) {
476
+ // Intake's publication order, the report last: a failure between files
477
+ // leaves the report bound to the old material, which reads as owed.
478
+ writeJsonAtomic(packetPath, nextPacket);
479
+ writeJsonAtomic(normalizedPath, buildBrief.artifact);
480
+ writeJsonAtomic(contextPath, saved.nextContext);
481
+ commitAssemblyReport(workspace, () => nextReport, {
482
+ command: "record brief",
483
+ staleReason: "The Campaign Build Brief was saved after this doctor snapshot. Re-run doctor (or next) for current state.",
484
+ });
485
+ }
486
+ }
487
+
488
+ return {
489
+ ok: true,
490
+ status: dryRun ? "dry_run" : "recorded",
491
+ action: "record",
492
+ stage: "brief",
493
+ outcome,
494
+ dry_run: dryRun,
495
+ brief_path: buildBrief.inputPath,
496
+ normalized_path: normalizedPath,
497
+ report_path: reportPath,
498
+ material,
499
+ ...(dryRun ? { would_write: writes } : { written: writes }),
500
+ demoted,
501
+ ...(outcome === "saved_with_invalidation" ? { input_change: { reason: changeReason(causes), at: timestamp } } : {}),
502
+ notices,
503
+ note: outcome === "unchanged"
504
+ ? "The brief is unchanged; nothing was written."
505
+ : dryRun
506
+ ? "Dry run: every check passed and nothing was written. Re-run without --dry-run to save."
507
+ : `Saved. Run ${cmd("next")} --packet <packet> for the next stage.`,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Run one `record spec` invocation: bind the CampaignSpec as it is now.
513
+ * Returns the result object the CLI prints; throws a refusal (nothing
514
+ * written) when a check fails.
515
+ */
516
+ export function recordSpec({ packetPath, contextPath = undefined, reportPath = undefined, dryRun = false }, { now = () => new Date() } = {}) {
517
+ return underTargetLock(packetPath, "record spec", dryRun, now, ({ lockedTarget, timestamp }) => refreshSpecUnderLock({
518
+ packetPath, sidecars: { contextPath, reportPath }, lockedTarget, dryRun, timestamp,
519
+ }));
520
+ }
521
+
522
+ // The CampaignSpec file and its parse, or null when it cannot be read or parsed.
523
+ function readSpecFile(specPath) {
524
+ if (!specPath || !existsSync(specPath)) return null;
525
+ try {
526
+ const bytes = readFileSync(specPath);
527
+ const spec = JSON.parse(bytes.toString("utf8"));
528
+ return isObject(spec) ? { spec, rawHash: createHash("sha256").update(bytes).digest("hex") } : null;
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+
534
+ // The brief file last saved, by intake or record brief: its --brief path,
535
+ // otherwise the file that save found; never discovery. Null when intake
536
+ // generated the guided draft and no brief file was saved since.
537
+ function recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) {
538
+ const explicit = optionalString(context?.intake?.brief_path);
539
+ if (explicit) return { path: resolve(targetRepo, explicit), source: "operator_flag" };
540
+ const found = optionalString(context?.build_brief?.input_path);
541
+ if (!found) return null;
542
+ return { path: resolve(targetRepo, found), source: optionalString(previousNormalizedBrief?._meta?.input_source) || "target_repo" };
543
+ }
544
+
545
+ // Every record spec refusal, in the fixed order: the spec checks first (the
546
+ // page-scope comparison needs a readable spec of the bound identity), then
547
+ // the recorded brief file. Returns [code, detail] or null.
548
+ function specRefusal({ read, specPath, packet, report, context, briefFile, packageArtifacts }) {
549
+ if (!read) return ["spec_unreadable", `the CampaignSpec at ${specPath || "(the packet names no spec.local_path)"} could not be read as a JSON object; restore it, then run ${cmd("record")} spec again.`];
550
+ const packetKind = specIdentityMismatch(read.spec, packet.spec);
551
+ const reportKind = isObject(report.identity) ? specIdentityMismatch(read.spec, report.identity) : null;
552
+ if (packetKind || reportKind) {
553
+ return ["spec_identity_changed", `the CampaignSpec's ${(packetKind || reportKind) === "local_spec" ? "local_spec_id" : "map_id"} names another campaign than the ${packetKind ? "Build Packet" : "Assembly Report"} binds; point spec.local_path at this campaign's spec. A new campaign identity needs intake.`];
554
+ }
555
+ if (pageScopeChanged(read.spec, context)) {
556
+ return ["page_scope_changed", `the CampaignSpec's active pages (ids, order or routes) differ from the Build Context's spec.active_pages. The Design Source Package and the source mapping are built per page, so a page-scope change needs intake (${cmd("prepare-build")}); with --force it clears recorded stage evidence and operator decisions, archiving the build, Polish and QA records.`];
557
+ }
558
+ if (!briefFile) return null;
559
+ return briefFileRefusal([briefFile], packageArtifacts, (file) => `the brief file last saved, ${briefFile.path}, ${file?.why || "does not exist"}; restore it, or save a brief file with ${cmd("record")} brief --brief <file>.`);
560
+ }
561
+
562
+ // The currency reasons that mean a stage's stamp differs from the current
563
+ // input. The replay reasons are not stamp changes: a refresh cannot clear them.
564
+ const STAMP_OWED_REASONS = Object.freeze(["spec_material_changed", "brief_material_changed"]);
565
+ const BRIEF_PARTITIONS = Object.freeze(["presentation", "qa_policy"]);
566
+
567
+ const sameBriefMaterial = (a, b) => wellFormedBriefMaterial(a) && wellFormedBriefMaterial(b)
568
+ && BRIEF_PARTITIONS.every((partition) => a[partition] === b[partition]);
569
+
570
+ function refreshSpecUnderLock({ packetPath, sidecars, lockedTarget, dryRun, timestamp }) {
571
+ const workspace = resolveCampaignWorkspace(packetPath, { ...sidecars, followContextPointer: true });
572
+ const { packet, contextPath, reportPath, targetRepo } = workspace;
573
+ if (targetRepo !== lockedTarget) throw refuseRecord("spec", [`The Build Packet was retargeted while this refresh waited for the target lock (${lockedTarget} is now ${targetRepo}); run ${cmd("record")} spec again.`]);
574
+ const report = readJsonOrNull(reportPath);
575
+ if (!isObject(report?.stages)) throw new Error(`record spec: no readable Assembly Report at ${reportPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
576
+ const context = readJsonOrNull(contextPath);
577
+ if (!isObject(context)) throw new Error(`record spec: no readable Build Context at ${contextPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
578
+ const specPath = optionalString(packet.spec?.local_path) ? resolveFromFile(packetPath, packet.spec.local_path) : null;
579
+ const read = readSpecFile(specPath);
580
+ const normalizedPath = optionalString(packet.build_brief?.normalized_path) ? resolveFromFile(packetPath, packet.build_brief.normalized_path) : null;
581
+ const previousNormalizedBrief = readJsonOrNull(normalizedPath);
582
+ const briefFile = normalizedPath ? recordedBriefFile(context, { targetRepo, previousNormalizedBrief }) : null;
583
+ const refusal = specRefusal({ read, specPath, packet, report, context, briefFile, packageArtifacts: packageArtifactPaths(workspace, normalizedPath) });
584
+ if (refusal) throw refuseRecord("spec", [`${refusal[0]}: ${refusal[1]}`]);
585
+
586
+ const { spec, rawHash } = read;
587
+ const material = currentSpecMaterial(spec);
588
+ const boundMaterial = optionalString(report.identity?.spec_material_hash);
589
+ const currentBrief = normalizedPath ? currentBriefMaterial({ packet, packetPath }) : absentBriefMaterial();
590
+ const currency = assessInputCurrency({ report, briefMaterial: currentBrief, specMaterial: material });
591
+ // Unchanged only when the bound material is the spec's and no stage's spec
592
+ // or brief stamp differs from the current input.
593
+ const owedOnStamp = STAMPED_STAGES.filter((key) => STAMP_OWED_REASONS.includes(currency.reasons[key]));
594
+ const notices = bindingUnknownNotices(currency);
595
+ const specChanged = !specHashesMatch(boundMaterial, material);
596
+ const unchanged = !specChanged && !owedOnStamp.length;
597
+
598
+ const writes = [];
599
+ let demoted = [];
600
+ let briefOutcome = null;
601
+ let reason = null;
602
+ if (!unchanged) {
603
+ // The brief re-derived with the new spec, under the record brief rules:
604
+ // an unchanged brief writes nothing of its own, a saved one rewrites the
605
+ // normalized brief and its bindings, and a material change also makes
606
+ // the stages its partitions name owed.
607
+ let brief = null;
608
+ if (normalizedPath) {
609
+ // Checked only when the brief is re-derived: an unchanged result reads
610
+ // nothing the missing intake products would give.
611
+ const missing = briefInputsMissing(context);
612
+ if (missing.length) throw refuseRecord("spec", [`brief_inputs_unavailable: the Build Context lacks ${missing.join(", ")}, which re-deriving the brief needs; re-run intake to restore it.`]);
613
+ brief = rederiveBrief({ briefFile, spec, context, packet, report, normalizedPath });
614
+ briefOutcome = briefSaveOutcome(report, brief.buildBrief, brief.bindings);
615
+ }
616
+ const briefSaved = briefOutcome && briefOutcome.outcome !== "unchanged";
617
+ // The normalized brief is rewritten when the save rules say so, and also
618
+ // when the file on disk no longer holds the re-derived material (an edit
619
+ // made to the normalized brief itself, which its source does not carry).
620
+ const briefWritten = Boolean(brief) && (briefSaved || !sameBriefMaterial(brief.bindings.material, currentBrief));
621
+ const newBrief = brief ? brief.bindings.material : currentBrief;
622
+ const saved = briefSaved
623
+ ? briefSaveArtifacts({ context, report, buildBrief: brief.buildBrief, bindings: brief.bindings, targetRepo })
624
+ : { nextContext: context, nextReport: report };
625
+ const labels = new Map(activeSpecPages(spec).map((page) => [page.id, page.label || null]));
626
+ const nextContext = {
627
+ ...saved.nextContext,
628
+ spec: {
629
+ ...saved.nextContext.spec,
630
+ hash: rawHash,
631
+ material_hash: material,
632
+ active_pages: saved.nextContext.spec.active_pages.map((page) => ({ ...page, label: labels.has(page.id) ? labels.get(page.id) : page.label ?? null })),
633
+ },
634
+ };
635
+ let nextReport = { ...saved.nextReport, identity: { ...saved.nextReport.identity, spec_hash: rawHash, spec_material_hash: material } };
636
+ // A stage is owed on the spec unless its spec stamp already equals the
637
+ // new material (a build recorded after the edit has recorded the change
638
+ // itself); a completed stage with no spec stamp is owed on it too. It is
639
+ // owed on a brief partition it depends on when the save rules invalidate
640
+ // that partition, or when its stamp of that partition differs from the
641
+ // re-derived brief. The spec reason wins where both apply.
642
+ const briefCauses = briefOutcome?.outcome === "saved_with_invalidation" ? briefOutcome.causes : [];
643
+ const causesFor = (key) => {
644
+ const record = nextReport.stages[key];
645
+ const stamp = record?.source_brief_material;
646
+ const specStamp = record?.source_spec_material_hash;
647
+ const specOwed = specStamp ? specStamp !== material : specChanged || COMPLETED_STATUSES.includes(record?.status);
648
+ const causes = specOwed ? ["spec"] : [];
649
+ for (const partition of BRIEF_PARTITIONS) {
650
+ if (!INPUT_DEPENDENCY_MAP[partition].stages[key]) continue;
651
+ const stampDiffers = wellFormedBriefMaterial(stamp) && wellFormedBriefMaterial(newBrief) && stamp[partition] !== newBrief[partition];
652
+ if (briefCauses.includes(partition) || stampDiffers) causes.push(partition);
653
+ }
654
+ return causes;
655
+ };
656
+ const allCauses = specChanged ? ["spec"] : [];
657
+ for (const key of STAMPED_STAGES) {
658
+ const causes = causesFor(key);
659
+ if (!causes.length) continue;
660
+ const applied = demoteStages(nextReport, { causes, now: timestamp, by: "record spec", only: [key] });
661
+ nextReport = applied.report;
662
+ demoted = [...demoted, ...applied.demoted];
663
+ allCauses.push(...causes);
664
+ }
665
+ reason = changeReason(allCauses);
666
+ const briefNote = briefSaved
667
+ ? `; brief re-derived (${briefOutcome.outcome})`
668
+ : briefWritten ? "; normalized brief re-derived from its source" : "";
669
+ nextReport.evidence = [
670
+ ...(Array.isArray(report.evidence) ? report.evidence : []),
671
+ `${timestamp} record spec: CampaignSpec material ${specChanged ? `${boundMaterial || "(none bound)"} -> ${material}` : `${material} already bound`}${briefNote}; ${demoted.length ? `${demoted.join(", ")} owed again` : "no recorded stage was demoted"}${reason ? ` (${reason})` : ""}.`,
672
+ ];
673
+ writes.push(...(briefWritten ? [normalizedPath] : []), contextPath, reportPath);
674
+ if (!dryRun) {
675
+ // Intake's publication order, the report last: a failure between files
676
+ // leaves the report bound to the old spec material, which reads as owed.
677
+ if (briefWritten) writeJsonAtomic(normalizedPath, brief.buildBrief.artifact);
678
+ writeJsonAtomic(contextPath, nextContext);
679
+ commitAssemblyReport(workspace, () => nextReport, {
680
+ command: "record spec",
681
+ staleReason: "The CampaignSpec was bound with record spec after this doctor snapshot. Re-run doctor (or next) for current state.",
682
+ });
683
+ }
684
+ }
685
+
686
+ const outcome = unchanged ? "unchanged" : "refreshed";
687
+ return {
688
+ ok: true,
689
+ status: dryRun ? "dry_run" : "recorded",
690
+ action: "record",
691
+ stage: "spec",
692
+ outcome,
693
+ dry_run: dryRun,
694
+ spec_path: specPath,
695
+ report_path: reportPath,
696
+ material: { bound: boundMaterial || null, current: material },
697
+ ...(dryRun ? { would_write: writes } : { written: writes }),
698
+ demoted,
699
+ ...(briefOutcome ? { brief: { outcome: briefOutcome.outcome, brief_path: briefFile?.path || null } } : {}),
700
+ ...(reason ? { input_change: { reason, at: timestamp } } : {}),
701
+ notices,
702
+ note: outcome === "unchanged"
703
+ ? "The CampaignSpec material is the one bound and no stage's stamp is owed; nothing was written."
704
+ : dryRun
705
+ ? "Dry run: every check passed and nothing was written. Re-run without --dry-run to bind the spec."
706
+ : `Bound. Run ${cmd("next")} --packet <packet> for the next stage.`,
707
+ };
708
+ }