@nextcommerce/campaigns-os 1.63.0 → 1.66.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 +62 -0
- package/campaign-spec/dist/types.d.ts +1 -1
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +129 -1
- package/contracts/release-ledger.json +549 -0
- package/contracts/supported-surface.json +3 -3
- package/docs/build-packet.md +31 -5
- package/docs/campaigns-os-build-flow.md +38 -0
- package/docs/campaignspec-authoring-examples.md +3 -2
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +44 -10
- package/docs/readback.md +8 -1
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/package.json +1 -1
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +17 -2
- 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 +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +3 -3
- package/skills.json +11 -11
- package/src/change-scope.mjs +25 -6
- package/src/cli.mjs +120 -7
- package/src/commercial-journey.mjs +3 -0
- package/src/commercial-parity.mjs +157 -7
- package/src/doctor/checks.mjs +28 -3
- package/src/doctor/ci.mjs +36 -1
- package/src/evidence-debt.mjs +9 -1
- package/src/input-currency.mjs +4 -2
- package/src/input-refresh.mjs +1 -1
- package/src/invocation.mjs +3 -2
- package/src/orchestration-stage-contract.mjs +3 -1
- package/src/polish-gate.mjs +10 -0
- package/src/qa-browser.mjs +46 -0
- package/src/qa-commercial-parity.mjs +173 -18
- package/src/qa-node.mjs +10 -5
- package/src/qa-verdict.mjs +3 -1
- package/src/readback.mjs +1 -0
- package/src/runtime-state-ignore.mjs +44 -19
- package/src/stage-ledger.mjs +35 -9
- package/src/stage-record.mjs +93 -14
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
|
|
7
7
|
// names it and `bundle check` validates it. Committed by design.
|
|
8
8
|
// 2. Handoff inputs and agent context (input/, theme/, agent-context/,
|
|
9
|
-
// setup-handoff.json)
|
|
9
|
+
// setup-handoff.json) and the CampaignSpec a Map intake fetched
|
|
10
|
+
// (fetched-specs/) — durable build handoff. The committed records are
|
|
11
|
+
// bound to that spec, and spec derive edits it, so another machine cannot
|
|
12
|
+
// get it back from the Map. Committed by design.
|
|
10
13
|
// 3. Telemetry, sessions, caches, and evidence — per-machine, append-only,
|
|
11
14
|
// or carrying live URLs and absolute paths. Never meant for the repo, and
|
|
12
15
|
// nothing wrote a rule for them: three campaign repositories were found
|
|
@@ -32,7 +35,6 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
32
35
|
// A project's second and later funnels keep their Run Records under their
|
|
33
36
|
// own runtime directory (src/campaign-workspace.mjs).
|
|
34
37
|
".campaign-runtime/funnels/*/run-records/",
|
|
35
|
-
".campaign-runtime/fetched-specs/",
|
|
36
38
|
".campaign-runtime/progress/",
|
|
37
39
|
".campaign-runtime/polish-evidence/",
|
|
38
40
|
".campaign-runtime/evidence/",
|
|
@@ -53,12 +55,17 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
53
55
|
// present.
|
|
54
56
|
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
|
|
55
57
|
|
|
58
|
+
// Entries an earlier toolkit wrote into the managed block that are now meant
|
|
59
|
+
// to be committed. They leave the managed block on the next setup command or
|
|
60
|
+
// `spec fetch`; the same line elsewhere in the file is the operator's own.
|
|
61
|
+
export const RUNTIME_STATE_RETIRED_IGNORED_PATHS = [".campaign-runtime/fetched-specs/"];
|
|
62
|
+
|
|
56
63
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
57
64
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
58
|
-
"# Sessions, journals, Run Records,
|
|
59
|
-
"#
|
|
60
|
-
"#
|
|
61
|
-
"#
|
|
65
|
+
"# Sessions, journals, Run Records, capture evidence, and full QA verdicts are",
|
|
66
|
+
"# per-machine. The readback bundle (build-context, assembly-report, doctor-output,",
|
|
67
|
+
"# qa-verdict), fetched-specs/, input/, theme/, and agent-context/ are meant to be",
|
|
68
|
+
"# committed and are deliberately not listed here.",
|
|
62
69
|
...RUNTIME_STATE_IGNORED_PATHS,
|
|
63
70
|
].join("\n");
|
|
64
71
|
|
|
@@ -66,14 +73,29 @@ function ignoreRuleLines(text) {
|
|
|
66
73
|
return new Set(text.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")));
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
//
|
|
70
|
-
// the
|
|
71
|
-
function
|
|
72
|
-
const lines = existing.split("\n");
|
|
73
|
-
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
76
|
+
// The managed block spans the marker up to the first blank line or the end of
|
|
77
|
+
// the file.
|
|
78
|
+
function managedBlockEnd(lines, markerIndex) {
|
|
74
79
|
let end = markerIndex + 1;
|
|
75
80
|
while (end < lines.length && lines[end].trim() !== "") end += 1;
|
|
76
|
-
|
|
81
|
+
return end;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function retiredEntriesInBlock(existing) {
|
|
85
|
+
const lines = existing.split("\n");
|
|
86
|
+
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
87
|
+
return lines.slice(markerIndex + 1, managedBlockEnd(lines, markerIndex)).map((line) => line.trim())
|
|
88
|
+
.filter((line) => RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Append the missing required entries at the end of the managed block and drop
|
|
92
|
+
// the retired ones from it.
|
|
93
|
+
function withBlockEntries(existing, missing) {
|
|
94
|
+
const lines = existing.split("\n");
|
|
95
|
+
const markerIndex = lines.findIndex((line) => line.trim() === RUNTIME_STATE_IGNORE_MARKER);
|
|
96
|
+
const end = managedBlockEnd(lines, markerIndex);
|
|
97
|
+
const block = lines.slice(markerIndex + 1, end).filter((line) => !RUNTIME_STATE_RETIRED_IGNORED_PATHS.includes(line.trim()));
|
|
98
|
+
lines.splice(markerIndex + 1, end - markerIndex - 1, ...block, ...missing);
|
|
77
99
|
return lines.join("\n");
|
|
78
100
|
}
|
|
79
101
|
|
|
@@ -81,10 +103,11 @@ function withRequiredEntries(existing, missing) {
|
|
|
81
103
|
* Ensure the target's .gitignore carries the runtime-state block. Idempotent
|
|
82
104
|
* on the marker line: an existing block, however edited beneath it, is left
|
|
83
105
|
* alone — except for RUNTIME_STATE_REQUIRED_IGNORED_PATHS, which are appended
|
|
84
|
-
* to an existing block when the file carries them nowhere
|
|
85
|
-
*
|
|
106
|
+
* to an existing block when the file carries them nowhere, and
|
|
107
|
+
* RUNTIME_STATE_RETIRED_IGNORED_PATHS, which are removed from it. Never throws
|
|
108
|
+
* for an unwritable target — the build must not fail on housekeeping — but
|
|
86
109
|
* reports what happened so callers can surface it.
|
|
87
|
-
* Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries? }`.
|
|
110
|
+
* Returns `{ path, action: "added" | "updated" | "present" | "skipped", reason?, added_entries?, removed_entries? }`.
|
|
88
111
|
*/
|
|
89
112
|
export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
|
|
90
113
|
const path = join(resolve(targetRepo), ".gitignore");
|
|
@@ -98,14 +121,16 @@ export function ensureRuntimeStateIgnored(targetRepo, { dryRun = false } = {}) {
|
|
|
98
121
|
if (existing.includes(RUNTIME_STATE_IGNORE_MARKER)) {
|
|
99
122
|
const present = ignoreRuleLines(existing);
|
|
100
123
|
const missing = RUNTIME_STATE_REQUIRED_IGNORED_PATHS.filter((entry) => !present.has(entry));
|
|
101
|
-
|
|
102
|
-
if (
|
|
124
|
+
const retired = retiredEntriesInBlock(existing);
|
|
125
|
+
if (missing.length === 0 && retired.length === 0) return { path, action: "present" };
|
|
126
|
+
const changes = { added_entries: missing, ...(retired.length ? { removed_entries: retired } : {}) };
|
|
127
|
+
if (dryRun) return { path, action: "updated", dry_run: true, ...changes };
|
|
103
128
|
try {
|
|
104
|
-
writeFileSync(path,
|
|
129
|
+
writeFileSync(path, withBlockEntries(existing, missing));
|
|
105
130
|
} catch (error) {
|
|
106
131
|
return { path, action: "skipped", reason: `unwritable: ${error.message}` };
|
|
107
132
|
}
|
|
108
|
-
return { path, action: "updated",
|
|
133
|
+
return { path, action: "updated", ...changes };
|
|
109
134
|
}
|
|
110
135
|
}
|
|
111
136
|
if (dryRun) return { path, action: "added", dry_run: true };
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -106,20 +106,23 @@ const HISTORY_ENTRY_FIELDS = Object.freeze([
|
|
|
106
106
|
"build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
|
|
107
107
|
"source_brief_material", "source_spec_material_hash",
|
|
108
108
|
"verdict_run_id", "checked_at", "purchase_proof",
|
|
109
|
-
"outputs", "evidence",
|
|
109
|
+
"outputs", "evidence", "baseline_accepted_at",
|
|
110
110
|
]);
|
|
111
111
|
// The entry fields that say what a record was, rather than when or by whom it
|
|
112
112
|
// was written: a replacement equal on all of them is a re-record, not a new
|
|
113
113
|
// chapter, and archives nothing.
|
|
114
114
|
const HISTORY_IDENTITY_FIELDS = Object.freeze([
|
|
115
115
|
"build_fingerprint", "source_build_fingerprint", "source_package_material_fingerprint",
|
|
116
|
-
"source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
|
|
116
|
+
"source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence", "baseline_accepted_at",
|
|
117
117
|
]);
|
|
118
|
-
export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
|
|
119
|
-
export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
|
|
118
|
+
export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "record baseline", "qa run", "prepare-build --force"]);
|
|
119
|
+
export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed", "baseline_accepted"]);
|
|
120
120
|
const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
|
|
121
121
|
|
|
122
122
|
const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
|
|
123
|
+
// A record a later one replaces into history. A baseline counts here, and not
|
|
124
|
+
// as completed: it never hands a campaign off or proves anything.
|
|
125
|
+
const isReplacedRecord = (status) => isCompletedStatus(status) || status === "baseline_accepted";
|
|
123
126
|
|
|
124
127
|
// What a record says about itself, compared for the dedup. Evidence reads
|
|
125
128
|
// through meaningfulEvidence (an empty block is absent) and a verdict id is
|
|
@@ -168,7 +171,7 @@ function historyEntry(previous, { by, reason, at }) {
|
|
|
168
171
|
// verdict identity or evidence, which the QA producer has always archived.
|
|
169
172
|
function archivable(stageKey, previous) {
|
|
170
173
|
if (!isPlainObject(previous)) return false;
|
|
171
|
-
if (
|
|
174
|
+
if (isReplacedRecord(previous.status)) return true;
|
|
172
175
|
return stageKey === "qa" && Boolean(recordIdentity(previous).verdict_run_id || meaningfulEvidence(previous.evidence));
|
|
173
176
|
}
|
|
174
177
|
|
|
@@ -200,6 +203,19 @@ export function archiveSupersededRecord(stage, previous, { by, reason, at }) {
|
|
|
200
203
|
return appendHistoryEntry(isPlainObject(stage) ? { ...stage } : {}, historyEntry(previous, { by, reason, at }));
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
// The baseline QA rests on, or null. The marker on the record is the rule, not
|
|
207
|
+
// its status: a blocked page-scoped run or an input change leaves QA resting on
|
|
208
|
+
// the baseline, and only a completed full QA clears the marker.
|
|
209
|
+
export function qaRestsOnBaseline(report) {
|
|
210
|
+
return report?.stages?.qa?.baseline_accepted_at && isPlainObject(report?.commerce_baseline) ? report.commerce_baseline : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A stage's baseline record, with what the stage held moved to its history.
|
|
214
|
+
export function archiveForBaseline(record, previous, { at }) {
|
|
215
|
+
const carried = Array.isArray(previous?.history) ? { ...record, history: previous.history } : record;
|
|
216
|
+
return archiveSupersededRecord(carried, previous, { by: "record baseline", reason: "baseline_accepted", at });
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
/**
|
|
204
220
|
* #668: the Polish record a CampaignSpec change moved to `polish.history`,
|
|
205
221
|
* restored as the live record stamped with `stamps` (the current inputs), or
|
|
@@ -241,6 +257,15 @@ export function campaignHandedOff(report) {
|
|
|
241
257
|
return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
|
|
242
258
|
}
|
|
243
259
|
|
|
260
|
+
// True when QA has ever completed, now or in the stage's history. Not
|
|
261
|
+
// campaignHandedOff: a baseline stands in for a QA result, and a completed
|
|
262
|
+
// deploy is not one.
|
|
263
|
+
export function qaEverCompleted(report) {
|
|
264
|
+
const qa = report?.stages?.qa;
|
|
265
|
+
return isPlainObject(qa) && (isCompletedStatus(qa.status)
|
|
266
|
+
|| (Array.isArray(qa.history) && qa.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
|
|
267
|
+
}
|
|
268
|
+
|
|
244
269
|
/**
|
|
245
270
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
246
271
|
* operator-visible state an intake must not silently drop.
|
|
@@ -301,7 +326,7 @@ export function archiveForForceReset(previousReport, nextReport, { now, detectCh
|
|
|
301
326
|
continue;
|
|
302
327
|
}
|
|
303
328
|
if (Object.hasOwn(previous, "input_change")) reseeded.input_change = JSON.parse(JSON.stringify(previous.input_change));
|
|
304
|
-
if (
|
|
329
|
+
if (isReplacedRecord(previous.status)) {
|
|
305
330
|
reseeded = archiveSupersededRecord(reseeded, previous, { by: "prepare-build --force", reason: "force_reset", at: now });
|
|
306
331
|
const reason = typeof detectChange === "function" ? detectChange(key, previous) : null;
|
|
307
332
|
if (reason) reseeded.input_change = inputChangeFor(key, previous, { at: now, reason });
|
|
@@ -405,7 +430,7 @@ export function recordProducerStageOutcome(report, {
|
|
|
405
430
|
// A replaced completed record whose stamps differ from the current inputs
|
|
406
431
|
// records the change; a verdict stamped with the current inputs then
|
|
407
432
|
// clears it, the one it just recorded included; any other write carries it.
|
|
408
|
-
const detected = inputs &&
|
|
433
|
+
const detected = inputs && isReplacedRecord(previous.status) ? inputs.detectChange(previous) : null;
|
|
409
434
|
if (detected) next.input_change = inputChangeFor("qa", previous, { at: timestamp, reason: detected });
|
|
410
435
|
if (inputs && inputs.stampsCurrent(next)) delete next.input_change;
|
|
411
436
|
const archived = archiveStageRecord(next, previous, next, { by: "qa run", reason: detected || "rerecorded", at: timestamp, stageKey: "qa" });
|
|
@@ -591,9 +616,10 @@ export function qaRecordedBuildFingerprint(report) {
|
|
|
591
616
|
// #720: the build whose completed QA a page-scoped run extends. A completed
|
|
592
617
|
// QA record extends its own build. A page-scoped run that did not complete
|
|
593
618
|
// extends the build of the full QA it was based on, which was completed when
|
|
594
|
-
// that run was accepted.
|
|
619
|
+
// that run was accepted. A baseline extends its own build too, and has no
|
|
620
|
+
// purchase proof to pass on. Any other record (blocked or pending full QA, a
|
|
595
621
|
// skip, a required stage) extends nothing, so it owes a full QA run.
|
|
596
|
-
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
|
|
622
|
+
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings", "baseline_accepted"]);
|
|
597
623
|
export function qaExtendedBuildFingerprint(report) {
|
|
598
624
|
const qa = report?.stages?.qa;
|
|
599
625
|
if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
|
package/src/stage-record.mjs
CHANGED
|
@@ -37,7 +37,9 @@ import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLo
|
|
|
37
37
|
import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
|
|
38
38
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
39
39
|
import { campaignRouteRoot, normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
40
|
-
import { buildChangeEntry, buildCommerceSignature, nextBuildChanges } from "./change-scope.mjs";
|
|
40
|
+
import { buildChangeEntry, buildCommerceSignature, nextBuildChanges, rebuiltFromBuildWithoutSignature } from "./change-scope.mjs";
|
|
41
|
+
import { isNamedHuman } from "./checkpoint-waiver.mjs";
|
|
42
|
+
import { singleLineFragment } from "./text-safety.mjs";
|
|
41
43
|
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
42
44
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
43
45
|
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
@@ -54,7 +56,7 @@ import {
|
|
|
54
56
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
55
57
|
import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
56
58
|
import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
|
|
57
|
-
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
59
|
+
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveForBaseline, archiveSupersededRecord, assemblyReportMatchesPacket, campaignHandedOff, qaEverCompleted, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
58
60
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
59
61
|
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
60
62
|
|
|
@@ -71,7 +73,9 @@ const BUILD_RECORD_FLAGS = Object.freeze(["build-environment", "adapter-decision
|
|
|
71
73
|
// discovery finds it).
|
|
72
74
|
const BRIEF_RECORD_FLAGS = Object.freeze(["brief"]);
|
|
73
75
|
// `record spec` binds the CampaignSpec as it is now; it takes no extra flag.
|
|
74
|
-
|
|
76
|
+
// `record baseline` accepts the current build as the starting point: who, and why.
|
|
77
|
+
const BASELINE_RECORD_FLAGS = Object.freeze(["accepted-by", "reason"]);
|
|
78
|
+
const RECORD_SUBCOMMANDS = Object.freeze([...RECORD_STAGES, "brief", "spec", "baseline"]);
|
|
75
79
|
// The page-kit environment the built output was rendered in, recorded on
|
|
76
80
|
// stages.assembly.evidence.build_environment (local proof mode builds in
|
|
77
81
|
// development; doctor and page-kit parity read it).
|
|
@@ -174,7 +178,7 @@ export function parseRecordArgs(args) {
|
|
|
174
178
|
if (!RECORD_SUBCOMMANDS.includes(stage) || args._.length !== 2) {
|
|
175
179
|
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("|")}>] [--adapter-decision <key=value[,key=value...]>], and record brief [--brief <yaml|json>].`);
|
|
176
180
|
}
|
|
177
|
-
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 : [])]);
|
|
181
|
+
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 : []), ...(stage === "baseline" ? BASELINE_RECORD_FLAGS : [])]);
|
|
178
182
|
const unknown = Object.keys(args).filter((key) => key !== "_" && !known.has(key));
|
|
179
183
|
if (unknown.length) {
|
|
180
184
|
throw refused(`Unknown flag${unknown.length > 1 ? "s" : ""} for record ${stage}: ${unknown.map((key) => `--${key}`).join(", ")}. Known flags: ${[...known].map((key) => `--${key}`).join(", ")}.`);
|
|
@@ -186,6 +190,10 @@ export function parseRecordArgs(args) {
|
|
|
186
190
|
throw refused(`--dry-run takes no value (got ${JSON.stringify(args["dry-run"])}); write \`--dry-run\` on its own, after the other flags.`);
|
|
187
191
|
}
|
|
188
192
|
if (Object.hasOwn(args, "json") && args.json !== true) throw refused("--json is a boolean flag and takes no value.");
|
|
193
|
+
if (stage === "baseline") {
|
|
194
|
+
if (!isNamedHuman(args["accepted-by"])) throw refused("record baseline: --accepted-by needs the named person accepting this build; placeholders and automation identities are not accepted.");
|
|
195
|
+
if (!optionalString(args.reason)) throw refused("record baseline: --reason needs the reason this build is being accepted untested.");
|
|
196
|
+
}
|
|
189
197
|
const buildEnvironment = Object.hasOwn(args, "build-environment") ? args["build-environment"] : null;
|
|
190
198
|
if (buildEnvironment !== null && !BUILD_ENVIRONMENTS.includes(buildEnvironment)) {
|
|
191
199
|
throw refused(`--build-environment must be one of: ${BUILD_ENVIRONMENTS.join(", ")} (got ${JSON.stringify(buildEnvironment)}).`);
|
|
@@ -201,6 +209,7 @@ export function parseRecordArgs(args) {
|
|
|
201
209
|
adapterDecisions,
|
|
202
210
|
briefPath: stage === "brief" && Object.hasOwn(args, "brief") ? resolve(args.brief) : null,
|
|
203
211
|
deviationReason: optionalString(args["deviation-reason"]),
|
|
212
|
+
baseline: stage === "baseline" ? { acceptedBy: args["accepted-by"].trim().replace(/\s+/g, " "), reason: args.reason.trim() } : null,
|
|
204
213
|
};
|
|
205
214
|
}
|
|
206
215
|
|
|
@@ -322,7 +331,7 @@ function composeSetup(report, context, { now, recordedBy }) {
|
|
|
322
331
|
// operator decision that applies only to the record that carried it.
|
|
323
332
|
const STAMP_FIELDS = Object.freeze(["source_brief_material", "source_spec_material_hash"]);
|
|
324
333
|
// A replaced record in any of these statuses is checked for an input change.
|
|
325
|
-
const REPLACED_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
|
|
334
|
+
const REPLACED_COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial", "baseline_accepted"]);
|
|
326
335
|
|
|
327
336
|
// The stamps a completed record writes: the inputs current at this write
|
|
328
337
|
// (the absent brief value for a packet without a brief). A value that cannot
|
|
@@ -473,6 +482,73 @@ function deployAfterBuild(previousDeploy, fingerprint, now) {
|
|
|
473
482
|
return archiveSupersededRecord(demoted, previousDeploy, { by: "record build", reason: "build_output_changed", at: now });
|
|
474
483
|
}
|
|
475
484
|
|
|
485
|
+
// `record baseline`: a named person accepts a funnel from before markup hashes
|
|
486
|
+
// as the starting point for page-only changes, without testing it. Polish and
|
|
487
|
+
// QA take the status baseline_accepted, which ends the ladder but is never
|
|
488
|
+
// completed; what they held moves to history, so its purchase proof stops
|
|
489
|
+
// counting. One time per funnel: report.commerce_baseline stays after a later
|
|
490
|
+
// full QA, so this can never be a way past QA that a commerce change owes.
|
|
491
|
+
function composeBaseline(report, { now, recordedBy, fingerprint, input, inputs, packet, packetPath }) {
|
|
492
|
+
const earlier = report.commerce_baseline;
|
|
493
|
+
if (isObject(earlier)) {
|
|
494
|
+
throw refuseRecord("baseline", [`${earlier.accepted_by} accepted a baseline for this funnel at ${earlier.accepted_at}, and a funnel gets one. Run ${cmd("qa")} run for what is owed now.`]);
|
|
495
|
+
}
|
|
496
|
+
if (!campaignHandedOff(report)) {
|
|
497
|
+
throw refuseRecord("baseline", ["This funnel has no completed deploy or QA, now or in its history. A baseline is for a funnel already handed off; run the full stages."]);
|
|
498
|
+
}
|
|
499
|
+
if (!rebuiltFromBuildWithoutSignature(report.stages?.assembly)) {
|
|
500
|
+
throw refuseRecord("baseline", [`This funnel's records have markup hashes (Campaigns OS 1.62.0 or later), or ${cmd("record")} build has not run on this toolkit. Page-only changes can be read from a full QA; a baseline is only for records from before those hashes. If you have not, run ${cmd("record")} build first.`]);
|
|
501
|
+
}
|
|
502
|
+
// A baseline stands in for a QA that passed on an older toolkit, so it needs
|
|
503
|
+
// one. A blocked QA found a problem a sign-off cannot vouch for.
|
|
504
|
+
const lastQa = stageObject(report, "qa");
|
|
505
|
+
if (lastQa.status === "blocked") {
|
|
506
|
+
const blockers = (Array.isArray(lastQa.blockers) ? lastQa.blockers : []).slice(0, 3).map((blocker) => singleLineFragment(typeof blocker === "string" ? blocker : blocker?.message || blocker?.code || "")).filter(Boolean);
|
|
507
|
+
throw refuseRecord("baseline", [`The last QA was blocked${blockers.length ? ` (${blockers.join("; ")})` : ""}, so it cannot be accepted untested. Run a full ${cmd("qa")} run; once it passes, page-only changes work without a baseline.`]);
|
|
508
|
+
}
|
|
509
|
+
if (!qaEverCompleted(report)) {
|
|
510
|
+
throw refuseRecord("baseline", [`No QA has completed on this funnel, so there is no result to start from. Run a full ${cmd("qa")} run; once it passes, page-only changes work without a baseline.`]);
|
|
511
|
+
}
|
|
512
|
+
const debt = assessEvidenceDebt({ packet, packetPath, report }).qa;
|
|
513
|
+
if (debt.status !== "owed" || debt.scope !== "full") {
|
|
514
|
+
throw refuseRecord("baseline", [`A full QA is not owed (QA reads ${debt.status}${debt.scope ? `, scope ${debt.scope}` : ""}), so there is nothing to accept.`]);
|
|
515
|
+
}
|
|
516
|
+
const stamps = currentStamps(inputs);
|
|
517
|
+
// History keeps no warnings or skip reasons, and a baseline must still say
|
|
518
|
+
// what it replaced.
|
|
519
|
+
const replacedRecord = (key) => {
|
|
520
|
+
const before = stageObject(report, key);
|
|
521
|
+
return { status: before.status ?? null, blockers: before.blockers ?? [], warnings: before.warnings ?? [], ...(before.skip_reason ? { skip_reason: before.skip_reason } : {}) };
|
|
522
|
+
};
|
|
523
|
+
const replace = (key, fields) => {
|
|
524
|
+
const previous = stageObject(report, key);
|
|
525
|
+
return archiveForBaseline({
|
|
526
|
+
stage: key,
|
|
527
|
+
status: "baseline_accepted",
|
|
528
|
+
recorded_by: recordedBy,
|
|
529
|
+
checked_at: now,
|
|
530
|
+
baseline_accepted_at: now,
|
|
531
|
+
...stamps,
|
|
532
|
+
blockers: [],
|
|
533
|
+
warnings: [],
|
|
534
|
+
outputs: [],
|
|
535
|
+
...fields,
|
|
536
|
+
}, previous, { at: now });
|
|
537
|
+
};
|
|
538
|
+
return {
|
|
539
|
+
report: {
|
|
540
|
+
...report,
|
|
541
|
+
commerce_baseline: { accepted_by: input.acceptedBy, accepted_at: now, reason: input.reason, build_fingerprint: fingerprint, replaced_polish: replacedRecord("polish"), replaced_qa: replacedRecord("qa") },
|
|
542
|
+
stages: {
|
|
543
|
+
...report.stages,
|
|
544
|
+
polish: replace("polish", { source_build_fingerprint: fingerprint }),
|
|
545
|
+
qa: replace("qa", { evidence: { source_build_fingerprint: fingerprint } }),
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
context: null,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
476
552
|
function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
|
|
477
553
|
const previous = stageObject(report, "polish");
|
|
478
554
|
const completed = POLISH_COMPLETED_STATUSES.includes(input.status);
|
|
@@ -1009,7 +1085,7 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
1009
1085
|
const binding = bindingProblems(doctor, report, packet);
|
|
1010
1086
|
if (binding.length) throw refuseRecord(stage, binding);
|
|
1011
1087
|
// The brand layer is applied to built output, so theme is checked as polish is.
|
|
1012
|
-
const ladder = ladderProblems(stage === "theme" ? "polish" : stage, doctor, report);
|
|
1088
|
+
const ladder = ladderProblems(stage === "theme" || stage === "baseline" ? "polish" : stage, doctor, report);
|
|
1013
1089
|
if (ladder.length) throw refuseRecord(stage, ladder);
|
|
1014
1090
|
if (stage === "setup") {
|
|
1015
1091
|
const outputDir = optionalString(derived.target_output_dir);
|
|
@@ -1026,13 +1102,13 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
1026
1102
|
if (stage === "build" && derived.scaffold_required === true) {
|
|
1027
1103
|
throw refuseRecord(stage, [`Setup is still required (${derived.scaffold_reason || "Build Context scaffold.required is true"}); run ${cmd("record")} setup first.`]);
|
|
1028
1104
|
}
|
|
1029
|
-
if (stage === "polish" || stage === "theme" || stage === "deploy") {
|
|
1105
|
+
if (stage === "polish" || stage === "theme" || stage === "deploy" || stage === "baseline") {
|
|
1030
1106
|
const recorded = optionalString(report?.stages?.assembly?.build_fingerprint);
|
|
1031
1107
|
if (!String(report?.stages?.assembly?.status || "").startsWith("completed") || !recorded) {
|
|
1032
1108
|
throw refuseRecord(stage, [`Build is not recorded (stages.assembly needs a completed status and build_fingerprint); run ${cmd("record")} build first.`]);
|
|
1033
1109
|
}
|
|
1034
1110
|
if (recorded !== fingerprint) {
|
|
1035
|
-
throw refuseRecord(stage, [stage === "theme" || stage === "deploy"
|
|
1111
|
+
throw refuseRecord(stage, [stage === "theme" || stage === "deploy" || stage === "baseline"
|
|
1036
1112
|
? `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then record ${stage} again.`
|
|
1037
1113
|
: `The built output changed since build was recorded (recorded ${recorded}, current ${fingerprint}); run ${cmd("record")} build, then ${cmd("polish")} capture, then record polish again.`]);
|
|
1038
1114
|
}
|
|
@@ -1143,12 +1219,12 @@ function readPacketFile(stage, packetPath) {
|
|
|
1143
1219
|
* composed or written.
|
|
1144
1220
|
*/
|
|
1145
1221
|
export function recordStageCommand(args, { now = () => new Date(), beforeLock = null, afterDoctorRead = null, probe = null } = {}) {
|
|
1146
|
-
const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason } = parseRecordArgs(args);
|
|
1222
|
+
const { stage, packetPath, evidencePath, dryRun, buildEnvironment, adapterDecisions, deviationReason, baseline } = parseRecordArgs(args);
|
|
1147
1223
|
if (!existsSync(packetPath)) throw new Error(`record ${stage}: Build Packet not found at ${packetPath}; run ${cmd("start")} or ${cmd("prepare-build")} first.`);
|
|
1148
1224
|
if (stage === "deploy" && !probe) throw new Error("record deploy needs the served-route probe; run it through recordCommand.");
|
|
1149
1225
|
// Operator input, not target state: no campaigns-os writer produces it.
|
|
1150
1226
|
// For deploy, the probe of the running server (probeLocalPreview).
|
|
1151
|
-
const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe :
|
|
1227
|
+
const input = stage === "polish" ? readPolishEvidenceFile(evidencePath) : stage === "deploy" ? probe : baseline;
|
|
1152
1228
|
const sidecars = {
|
|
1153
1229
|
contextPath: args.context ? resolve(args.context) : undefined,
|
|
1154
1230
|
reportPath: args.report ? resolve(args.report) : undefined,
|
|
@@ -1169,7 +1245,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1169
1245
|
const recorded = dryRun ? run() : withTargetLockSync(lockedTarget, run, { command: `record ${stage}` });
|
|
1170
1246
|
const { composed, facts, layer, reportPath, contextPath, after } = recorded;
|
|
1171
1247
|
|
|
1172
|
-
const stageKey = stage === "build" ? "assembly" : stage;
|
|
1248
|
+
const stageKey = stage === "build" ? "assembly" : stage === "baseline" ? "qa" : stage;
|
|
1173
1249
|
const writes = [...(composed.context ? [contextPath] : []), ...(composed.packet ? [packetPath] : []), reportPath];
|
|
1174
1250
|
const ready = stage === "deploy" ? [
|
|
1175
1251
|
`stages.deploy.status = completed; deploy.preview_url = ${input.url}`,
|
|
@@ -1186,6 +1262,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1186
1262
|
...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
|
|
1187
1263
|
...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
|
|
1188
1264
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
1265
|
+
...(stage === "baseline" ? [`Polish and QA read baseline_accepted: ${baseline.acceptedBy} accepted build ${facts.fingerprint}. Nothing was tested, and no purchase is proven from here until a full ${cmd("qa")} run.`] : []),
|
|
1189
1266
|
...(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)`] : []),
|
|
1190
1267
|
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
1191
1268
|
];
|
|
@@ -1200,7 +1277,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1200
1277
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
1201
1278
|
build_fingerprint: facts.fingerprint || null,
|
|
1202
1279
|
// The manifest is kept in the report, not echoed: it lists every built file.
|
|
1203
|
-
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
|
|
1280
|
+
record: stage === "baseline" ? composed.report.commerce_baseline : stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
|
|
1204
1281
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
1205
1282
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
1206
1283
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -1273,7 +1350,9 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1273
1350
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
1274
1351
|
: stage === "deploy"
|
|
1275
1352
|
? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input, fingerprint: facts.fingerprint })
|
|
1276
|
-
:
|
|
1353
|
+
: stage === "baseline"
|
|
1354
|
+
? composeBaseline(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor), packet, packetPath })
|
|
1355
|
+
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
|
|
1277
1356
|
applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
|
|
1278
1357
|
// The packet's one new value, deploy.preview_url, is checked by
|
|
1279
1358
|
// deployPreviewUrl; the rest of the packet is as the operator left it.
|
|
@@ -1290,7 +1369,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1290
1369
|
// Already inside the target lock, which commitAssemblyReport re-enters.
|
|
1291
1370
|
commitAssemblyReport(workspace, compose, {
|
|
1292
1371
|
command: `record ${stage}`,
|
|
1293
|
-
staleReason: `${stage === "theme" ? "theme" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
1372
|
+
staleReason: `${stage === "theme" ? "theme" : stage === "baseline" ? "a baseline" : `stages.${stage === "build" ? "assembly" : stage}`} was recorded after this doctor snapshot. Re-run ${cmd("doctor")} (or next) for current state.`,
|
|
1294
1373
|
...(dryRun ? { lock: false } : {}),
|
|
1295
1374
|
});
|
|
1296
1375
|
const after = dryRun ? null : doctorPacket(packetPath, sidecars);
|