@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.
- package/CHANGELOG.md +48 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +201 -12
- package/contracts/release-ledger.json +327 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +56 -15
- package/docs/campaign-build-brief.md +48 -17
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +2 -0
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/cli.mjs +160 -112
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +129 -14
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +3 -1
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +15 -3
- package/src/qa-browser.mjs +131 -84
- package/src/qa-node.mjs +14 -3
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +119 -26
- 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
|
+
}
|