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