@nextcommerce/campaigns-os 1.61.0 → 1.62.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/AGENTS.md +2 -1
- package/CHANGELOG.md +118 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +999 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +29 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- 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 +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
package/src/qc-results.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
22
22
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
23
23
|
|
|
24
|
-
import { campaignSidecarPaths } from "./campaign-workspace.mjs";
|
|
24
|
+
import { campaignSidecarPaths, packetRuntimeRelPath } from "./campaign-workspace.mjs";
|
|
25
25
|
import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
|
|
26
26
|
import { contrastToolkit } from "./contrast.mjs";
|
|
27
27
|
import { sameFile } from "./fs-identity.mjs";
|
|
@@ -299,10 +299,14 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
|
|
|
299
299
|
const stats = statSync(real);
|
|
300
300
|
if (!stats.isFile()) return null;
|
|
301
301
|
if (!inside(realpathSync(outputDir), real)) return null;
|
|
302
|
+
// This funnel's default report and every other funnel's project-level
|
|
303
|
+
// one (#719): neither is a verdict.
|
|
304
|
+
const packet = isNonEmptyString(packetPath) ? readPacketForGuard(packetPath) : null;
|
|
302
305
|
const forbidden = [
|
|
303
306
|
reportPath,
|
|
304
307
|
campaignSidecarPaths(targetRepo).reportPath,
|
|
305
|
-
|
|
308
|
+
packet ? campaignSidecarPaths(targetRepo, packetRuntimeRelPath(packet)).reportPath : null,
|
|
309
|
+
isNonEmptyString(packetPath) ? sidecarPathForPacket(packetPath, packet) : null,
|
|
306
310
|
...outputs.filter((path) => path !== candidates[0]),
|
|
307
311
|
].filter(isNonEmptyString);
|
|
308
312
|
for (const path of forbidden) {
|
|
@@ -317,6 +321,15 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
|
|
|
317
321
|
}
|
|
318
322
|
}
|
|
319
323
|
|
|
324
|
+
function readPacketForGuard(packetPath) {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(readFileSync(packetPath, "utf8"));
|
|
327
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
328
|
+
} catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
320
333
|
// `qaCurrency` is QA's read-time input currency
|
|
321
334
|
// (derived.input_currency.stages.qa). While QA is owed again or its inputs
|
|
322
335
|
// cannot be confirmed, every row that reproduces reads stale_binding, as a
|
|
@@ -1038,30 +1051,46 @@ function readabilityCropResolves(ref, targetRepo) {
|
|
|
1038
1051
|
// the record lacks, or one over the route cap, reads page_not_captured;
|
|
1039
1052
|
// a recorded route outside it, or different uncaptured routes or
|
|
1040
1053
|
// enumeration cap, → evidence_not_reproducible;
|
|
1041
|
-
// 4. the rows READABILITY_QC_RULES.evaluate derives from each cell
|
|
1054
|
+
// 4. the rows READABILITY_QC_RULES.evaluate derives from each cell, and,
|
|
1055
|
+
// for a record that stores layout_conventions, the layout.conventions
|
|
1056
|
+
// rows LAYOUT_QC_RULES.evaluate derives from it.
|
|
1042
1057
|
// `campaignSlug` is the packet's slug; a missing one never matches.
|
|
1043
|
-
export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput =
|
|
1058
|
+
export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = undefined, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
|
|
1044
1059
|
if (record === undefined || record === null) return [];
|
|
1045
|
-
const [check] = POLISH_RECORDS.readability.checks;
|
|
1060
|
+
const [check, layoutCheck = null] = POLISH_RECORDS.readability.checks;
|
|
1046
1061
|
const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
|
|
1047
1062
|
const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
|
|
1048
|
-
const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
|
|
1049
|
-
const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
|
|
1063
|
+
const subjectOf = (page, viewport, key, of = check) => ({ check: of, page, viewport, key });
|
|
1064
|
+
const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check: subject.check }, reasonCode, { leg: "polish", measuredAt });
|
|
1050
1065
|
const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
|
|
1051
1066
|
let rules = null;
|
|
1067
|
+
// The layout check reads only a record that stores the conventions it was
|
|
1068
|
+
// measured with; its rules load on their own.
|
|
1069
|
+
const layoutRecorded = isPlainObject(record) && Object.hasOwn(record, "layout_conventions") && isNonEmptyString(layoutCheck);
|
|
1070
|
+
const layoutResolved = layoutRecorded ? resolvePolishRules({ check: layoutCheck, qcStandIns, rederivers }) : { status: "missing" };
|
|
1071
|
+
const layoutRules = layoutResolved.status === "loaded" ? layoutResolved.rules : null;
|
|
1072
|
+
const layoutRead = layoutRecorded && layoutResolved.status !== "missing";
|
|
1073
|
+
const namedKeys = (rowKeys, cell) => {
|
|
1074
|
+
try {
|
|
1075
|
+
const named = typeof rowKeys === "function" ? rowKeys(cell) : [];
|
|
1076
|
+
return Array.isArray(named) ? named.filter(isNonEmptyString) : [];
|
|
1077
|
+
} catch {
|
|
1078
|
+
return [];
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
// A failed cell's layout rows: one per row the layout rules name for the
|
|
1082
|
+
// cell (its `cell` row among them; none at a viewport no convention
|
|
1083
|
+
// applies to), or its `cell` row when the rules did not load.
|
|
1084
|
+
const failLayout = (cell) => (layoutRead
|
|
1085
|
+
? [...new Set(layoutRules ? namedKeys(layoutRules.rowKeys, cell) : ["cell"])].map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key, layoutCheck), E))
|
|
1086
|
+
: []);
|
|
1052
1087
|
// A failed cell: one result per row the cell names and its `cell` row, so
|
|
1053
1088
|
// a failed cell is never silent and an accept of one of its rows lapses
|
|
1054
1089
|
// rather than orphans. The keys come from rules.rowKeys, which reads
|
|
1055
1090
|
// neither the record's thresholds nor any field the checks reject.
|
|
1056
1091
|
const failCell = (cell) => {
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
|
|
1060
|
-
} catch {
|
|
1061
|
-
named = [];
|
|
1062
|
-
}
|
|
1063
|
-
const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
|
|
1064
|
-
return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
|
|
1092
|
+
const keys = [...new Set([...namedKeys(rules?.rowKeys, cell), "cell"])];
|
|
1093
|
+
return [...keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E)), ...failLayout(cell)];
|
|
1065
1094
|
};
|
|
1066
1095
|
// A record-level failure: every listed cell fails; every route the subject
|
|
1067
1096
|
// or a cell names, at every recorded or fixed viewport, that no cell lists
|
|
@@ -1124,7 +1153,8 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1124
1153
|
|| typeof record.route_enumeration_capped !== "boolean"
|
|
1125
1154
|
|| !Array.isArray(record.cells)
|
|
1126
1155
|
|| cells.length !== record.cells.length
|
|
1127
|
-
|| !cells.every((cell) => rules.cellShapeOk(cell))
|
|
1156
|
+
|| !cells.every((cell) => rules.cellShapeOk(cell))
|
|
1157
|
+
|| (layoutRecorded && layoutRules && !sameJson(record.layout_conventions, layoutRules.thresholds))) return failAll();
|
|
1128
1158
|
// The cell set is exactly routes × viewports: none missing, none added,
|
|
1129
1159
|
// none twice, every cell's route and viewport in the subject.
|
|
1130
1160
|
const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
|
|
@@ -1151,7 +1181,9 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1151
1181
|
const bound = isNonEmptyString(currentBuild)
|
|
1152
1182
|
&& subject.build_fingerprint === currentBuild
|
|
1153
1183
|
&& subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
|
|
1154
|
-
|
|
1184
|
+
// As in the captures reader: no buildOutput (undefined) reads without the
|
|
1185
|
+
// output check; null is drift.
|
|
1186
|
+
&& !outputDrifted(buildOutput);
|
|
1155
1187
|
|
|
1156
1188
|
// 3. The recomputed route set, read only when the output is the build's.
|
|
1157
1189
|
const results = [];
|
|
@@ -1182,22 +1214,37 @@ export function readReadability({ record, pageLoad = null, currentBuild = null,
|
|
|
1182
1214
|
}
|
|
1183
1215
|
const derived = evaluate(cell, cropAvailable);
|
|
1184
1216
|
// Every derived subject is the cell's own page and viewport, each key once.
|
|
1185
|
-
const
|
|
1186
|
-
|
|
1187
|
-
&&
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1217
|
+
const derivedOk = (items, of) => {
|
|
1218
|
+
const keys = Array.isArray(items) ? items.map((item) => item?.subject?.key) : [];
|
|
1219
|
+
return Array.isArray(items) && new Set(keys).size === keys.length && items.every((item) => item?.check === of
|
|
1220
|
+
&& validDerived(item, of)
|
|
1221
|
+
&& hasExactly(item.subject, ["check", "page", "viewport", "key"])
|
|
1222
|
+
&& item.subject.page === cell.route
|
|
1223
|
+
&& item.subject.viewport === cell.viewport
|
|
1224
|
+
&& isNonEmptyString(item.subject.key));
|
|
1225
|
+
};
|
|
1226
|
+
if (!derivedOk(derived, check)) {
|
|
1192
1227
|
results.push(...failCell(cell));
|
|
1193
1228
|
continue;
|
|
1194
1229
|
}
|
|
1195
|
-
|
|
1230
|
+
let layoutDerived = [];
|
|
1231
|
+
if (layoutRead) {
|
|
1232
|
+
try {
|
|
1233
|
+
layoutDerived = layoutRules ? layoutRules.evaluate(cell, record.layout_conventions) : null;
|
|
1234
|
+
} catch {
|
|
1235
|
+
layoutDerived = null;
|
|
1236
|
+
}
|
|
1237
|
+
if (!derivedOk(layoutDerived, layoutCheck)) {
|
|
1238
|
+
results.push(...failLayout(cell));
|
|
1239
|
+
layoutDerived = [];
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
for (const item of [...derived, ...layoutDerived]) {
|
|
1196
1243
|
const members = item.members ?? [];
|
|
1197
1244
|
const row = {
|
|
1198
1245
|
schema: QC_RESULT_SCHEMA,
|
|
1199
1246
|
id: qcResultId(item.subject),
|
|
1200
|
-
check,
|
|
1247
|
+
check: item.check,
|
|
1201
1248
|
leg: "polish",
|
|
1202
1249
|
result: item.result,
|
|
1203
1250
|
reason_code: item.result === "pass" ? null : item.reason_code,
|
package/src/readback.mjs
CHANGED
|
@@ -56,6 +56,8 @@ import { fileURLToPath } from "node:url";
|
|
|
56
56
|
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
|
|
57
57
|
import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
|
|
58
58
|
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
59
|
+
import { RUNTIME_DIR_REL_PATH, packetRuntimeRelPath } from "./campaign-workspace.mjs";
|
|
60
|
+
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
59
61
|
|
|
60
62
|
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
61
63
|
export const MAX_GIT_METADATA_BYTES = 64 * 1024;
|
|
@@ -193,7 +195,9 @@ export function readReflogTail(path) {
|
|
|
193
195
|
|
|
194
196
|
// Artifact keys, in render order, with the default location of each artifact
|
|
195
197
|
// relative to the target repository root. The packet lives at the root; the
|
|
196
|
-
// sidecars live under .campaign-runtime
|
|
198
|
+
// sidecars live under .campaign-runtime/, or under the runtime directory the
|
|
199
|
+
// selected packet records (assembly.runtime_dir) when the project holds
|
|
200
|
+
// several funnels.
|
|
197
201
|
export const DEFAULT_RELATIVE_PATHS = {
|
|
198
202
|
packet: "campaign-runtime.build.json",
|
|
199
203
|
doctor: ".campaign-runtime/doctor-output.json",
|
|
@@ -221,8 +225,10 @@ export const RECOGNIZED_SCHEMA_VERSIONS = {
|
|
|
221
225
|
};
|
|
222
226
|
|
|
223
227
|
// Root-level Build Packets other than the default-named one. A second run in
|
|
224
|
-
// the same repository leaves its record under a suffixed name
|
|
225
|
-
//
|
|
228
|
+
// the same repository leaves its record under a suffixed name, and every
|
|
229
|
+
// funnel after a project's first has its packet at
|
|
230
|
+
// campaign-runtime-<route>.build.json; both shapes are discovery candidates
|
|
231
|
+
// when no `--packet` was given.
|
|
226
232
|
const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
|
|
227
233
|
|
|
228
234
|
// Sidecar path some write-ends have used instead of the contracted root home.
|
|
@@ -1304,6 +1310,13 @@ function renderEvidenceDebt(debt, lines) {
|
|
|
1304
1310
|
: `evidence currency is unknown (${stage.reason}); absence of proof is not no debt`;
|
|
1305
1311
|
lines.push(` ${name}: ${stage.status} — ${explanation}`);
|
|
1306
1312
|
lines.push(` recorded: ${stage.recorded_build_fingerprint ?? "unknown"}; current: ${stage.current_build_fingerprint ?? "unknown"}`);
|
|
1313
|
+
if (stage.change) {
|
|
1314
|
+
const owes = stage.advisory ? "advisory: reported, not required"
|
|
1315
|
+
: stage.scope === "pages" ? `owed on ${stage.owed_pages.map((page) => oneLine(page.page_id)).join(", ")} (qa run --page)` : "the full stage is owed";
|
|
1316
|
+
lines.push(` change since its build: ${stage.change.kind}; ${owes}`);
|
|
1317
|
+
for (const page of stage.change.changed_pages) lines.push(` changed page: ${oneLine(page.page_id)} (${oneLine(page.built_path)})`);
|
|
1318
|
+
for (const reason of stage.change.reasons) lines.push(` commerce: ${oneLine(reason.code)}${reason.path ? ` (${oneLine(reason.path)})` : ""}`);
|
|
1319
|
+
}
|
|
1307
1320
|
}
|
|
1308
1321
|
const changes = debt.changes;
|
|
1309
1322
|
lines.push(` changes against assembly manifest: ${changes.status}${changes.reason ? ` (${changes.reason})` : ""}`);
|
|
@@ -1642,7 +1655,8 @@ export function serializeStaleness(staleness) {
|
|
|
1642
1655
|
* or unrecognized one always is); the staleness comparison is computable and NO
|
|
1643
1656
|
* loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
|
|
1644
1657
|
* readback could not parse; no cross-artifact divergence was found; and the
|
|
1645
|
-
* doctor output records zero errors; and no evidence-debt stage is owed
|
|
1658
|
+
* doctor output records zero errors; and no evidence-debt stage is owed
|
|
1659
|
+
* (advisory Polish for a page-only change does not count).
|
|
1646
1660
|
* Unknown build binding does not independently change this integrity flag;
|
|
1647
1661
|
* callers must read evidence_debt rather than treating clean as no debt.
|
|
1648
1662
|
*
|
|
@@ -1664,7 +1678,8 @@ export function serializeStaleness(staleness) {
|
|
|
1664
1678
|
*/
|
|
1665
1679
|
export function computeClean(views, staleness, evidenceDebt) {
|
|
1666
1680
|
if (evidenceDebt == null) throw new TypeError("computeClean requires an evidence-debt assessment");
|
|
1667
|
-
|
|
1681
|
+
// Advisory Polish (a page-only change) is reported, never required.
|
|
1682
|
+
if ([evidenceDebt?.polish, evidenceDebt?.qa].some((stage) => stage?.status === "owed" && !stage.advisory)) return false;
|
|
1668
1683
|
if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
|
|
1669
1684
|
if (!staleness || !staleness.computable || staleness.stale) return false;
|
|
1670
1685
|
if (staleness.unparseable_keys.length) return false;
|
|
@@ -1835,6 +1850,18 @@ export function selectPacketPath(rootPath, override) {
|
|
|
1835
1850
|
|
|
1836
1851
|
if (considered.length === 1) return chose(considered[0], "sole_candidate");
|
|
1837
1852
|
|
|
1853
|
+
// Packets for different routes are different funnels, each with its own
|
|
1854
|
+
// records: freshness would only say which funnel was set up last, so the
|
|
1855
|
+
// caller names the one to project.
|
|
1856
|
+
const routes = new Map(considered.map((name) => [name, normalizePublicRouteSlug(views.get(name).data?.campaign?.public_route_slug) || null]));
|
|
1857
|
+
if (new Set(routes.values()).size > 1) {
|
|
1858
|
+
const detail = considered.map((name) => `${name} (route ${routes.get(name) || "unnamed"})`).join(", ");
|
|
1859
|
+
throw new ReadbackUsageError(
|
|
1860
|
+
`the target root holds Build Packets for more than one funnel: ${detail}. ` +
|
|
1861
|
+
"Pass --packet to name the funnel's Build Packet to project.",
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1838
1865
|
if (considered.some((name) => times.get(name) === null)) {
|
|
1839
1866
|
const detail = considered
|
|
1840
1867
|
.map((name) => {
|
|
@@ -1869,24 +1896,45 @@ export function selectPacketPath(rootPath, override) {
|
|
|
1869
1896
|
/**
|
|
1870
1897
|
* Return `{ paths, packetSelection, packetView }` for one target root.
|
|
1871
1898
|
*
|
|
1872
|
-
* `packetView` is the Build Packet view
|
|
1873
|
-
*
|
|
1874
|
-
*
|
|
1899
|
+
* `packetView` is the Build Packet view already loaded (by discovery, or here
|
|
1900
|
+
* for an explicit `--packet` that names a file), ready to hand to
|
|
1901
|
+
* loadArtifacts as `preloaded` so no artifact is read twice; it is null when
|
|
1902
|
+
* there is no packet file to read. The sidecar paths sit under the runtime
|
|
1903
|
+
* directory that packet records.
|
|
1875
1904
|
*/
|
|
1876
1905
|
export function resolveProjection(root, overrides = {}) {
|
|
1877
1906
|
const info = statOrNull(root);
|
|
1878
1907
|
if (!info || !info.isDirectory()) {
|
|
1879
1908
|
throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
|
|
1880
1909
|
}
|
|
1881
|
-
const
|
|
1910
|
+
const selected = selectPacketPath(root, overrides.packet);
|
|
1911
|
+
const { path: packetPath, selection } = selected;
|
|
1912
|
+
// The sidecars follow the packet: read it now when discovery did not, so
|
|
1913
|
+
// its runtime directory is known and it is still read only once.
|
|
1914
|
+
const view = selected.view || (isFilePath(packetPath) ? loadArtifact("packet", packetPath) : null);
|
|
1915
|
+
const runtime = packetRuntimeDirFor(view);
|
|
1882
1916
|
const paths = { packet: packetPath };
|
|
1883
1917
|
for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
|
|
1884
1918
|
if (key === "packet") continue;
|
|
1885
|
-
paths[key] = overrides[key] ? String(overrides[key]) : join(root, relative);
|
|
1919
|
+
paths[key] = overrides[key] ? String(overrides[key]) : join(root, runtime, relative.slice(RUNTIME_DIR_REL_PATH.length + 1));
|
|
1886
1920
|
}
|
|
1887
1921
|
return { paths, packetSelection: selection, packetView: view };
|
|
1888
1922
|
}
|
|
1889
1923
|
|
|
1924
|
+
// The runtime directory the selected packet records, relative to the root;
|
|
1925
|
+
// the project-level .campaign-runtime when there is no readable packet or it
|
|
1926
|
+
// records none. A value the packet may not carry leaves no way to know whose
|
|
1927
|
+
// records to show, and showing the project-level ones could project another
|
|
1928
|
+
// funnel's, so the readback cannot form a projection.
|
|
1929
|
+
function packetRuntimeDirFor(view) {
|
|
1930
|
+
if (view?.state !== "loaded") return RUNTIME_DIR_REL_PATH;
|
|
1931
|
+
try {
|
|
1932
|
+
return packetRuntimeRelPath(view.data);
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
throw new ReadbackUsageError(`${view.path}: ${error.message}`);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1890
1938
|
/** The artifact paths to project; discovery result discarded. */
|
|
1891
1939
|
export function resolvePaths(root, overrides = {}) {
|
|
1892
1940
|
return resolveProjection(root, overrides).paths;
|
package/src/route-identity.mjs
CHANGED
|
@@ -39,6 +39,16 @@ export function normalizePageKitRoute(value) {
|
|
|
39
39
|
return clean ? `${clean}/` : "";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// The public route slug a CampaignSpec names for itself, normalized:
|
|
43
|
+
// spec_identity.public_route_slug, else campaign.slug, else campaign.id. One
|
|
44
|
+
// reading for every command that checks a spec belongs to its packet.
|
|
45
|
+
export function specPublicRouteSlug(spec) {
|
|
46
|
+
for (const value of [spec?.spec_identity?.public_route_slug, spec?.campaign?.slug, spec?.campaign?.id]) {
|
|
47
|
+
if (typeof value === "string" && value.trim()) return normalizePublicRouteSlug(value);
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
// The route with the slug prefix removed, when it carries one: "slug/checkout/"
|
|
43
53
|
// → "checkout/", "slug/" → "", "checkout/" → "checkout/".
|
|
44
54
|
export function stripPublicRoutePrefix(route, publicRouteSlug) {
|
package/src/run-record.mjs
CHANGED
|
@@ -12,13 +12,14 @@ import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-
|
|
|
12
12
|
|
|
13
13
|
import { randomBytes } from "node:crypto";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
16
|
import { ADAPTER_DECISION_STRATEGY_FIELDS } from "./adapter-decision-contract.mjs";
|
|
17
17
|
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
18
18
|
import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
19
19
|
|
|
20
20
|
export const RUN_RECORD_SCHEMA = "campaigns-os-run-record/v0";
|
|
21
21
|
export const RUN_RECORDS_DIR_REL_PATH = ".campaign-runtime/run-records";
|
|
22
|
+
const RUNTIME_DIR_REL_PATH = ".campaign-runtime";
|
|
22
23
|
|
|
23
24
|
// Improvement-surface taxonomy: a list, not an enum — real signal is rarely
|
|
24
25
|
// one surface (see Improvement-Surface Taxonomy in the design).
|
|
@@ -377,12 +378,19 @@ function sanitizeRunId(runId) {
|
|
|
377
378
|
}
|
|
378
379
|
|
|
379
380
|
/**
|
|
380
|
-
* Resolve where a Run Record is written: <baseDir
|
|
381
|
-
*
|
|
382
|
-
*
|
|
381
|
+
* Resolve where a Run Record is written: <baseDir>/<runtime dir>/run-records/
|
|
382
|
+
* <run_id>.json. baseDir is the run root (the CLI passes the packet's
|
|
383
|
+
* directory) so the records sit next to the findings journal. The runtime
|
|
384
|
+
* directory is the packet's (packetRuntimeRelPath in campaign-workspace.mjs):
|
|
385
|
+
* `.campaign-runtime` unless the packet is one of several funnels in its
|
|
386
|
+
* project, so each funnel keeps its own records.
|
|
383
387
|
*/
|
|
384
|
-
export function resolveRunRecordPath(runId, baseDir = process.cwd()) {
|
|
385
|
-
return join(
|
|
388
|
+
export function resolveRunRecordPath(runId, baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
389
|
+
return join(runRecordsDir(baseDir, runtimeRelPath), `${sanitizeRunId(runId)}.json`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function runRecordsDir(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
393
|
+
return join(resolve(baseDir), runtimeRelPath, relative(RUNTIME_DIR_REL_PATH, RUN_RECORDS_DIR_REL_PATH));
|
|
386
394
|
}
|
|
387
395
|
|
|
388
396
|
function extractDoctorObservations(doctor) {
|
|
@@ -758,13 +766,13 @@ export function assembleRunRecord({
|
|
|
758
766
|
* invalid record (a malformed manifest is a build-tooling bug, not run state
|
|
759
767
|
* to swallow). Returns the written path.
|
|
760
768
|
*/
|
|
761
|
-
export function writeRunRecord(record, { baseDir = process.cwd() } = {}) {
|
|
769
|
+
export function writeRunRecord(record, { baseDir = process.cwd(), runtimeRelPath = RUNTIME_DIR_REL_PATH } = {}) {
|
|
762
770
|
const validation = validateRunRecord(record);
|
|
763
771
|
if (!validation.ok) {
|
|
764
772
|
const detail = validation.errors.map((error) => `[${error.code}] ${error.message}`).join("; ");
|
|
765
773
|
throw new Error(`Run Record failed validation; refusing to write: ${detail}`);
|
|
766
774
|
}
|
|
767
|
-
const path = resolveRunRecordPath(record.run_id, baseDir);
|
|
775
|
+
const path = resolveRunRecordPath(record.run_id, baseDir, runtimeRelPath);
|
|
768
776
|
mkdirSync(dirname(path), { recursive: true });
|
|
769
777
|
const tmpPath = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
770
778
|
writeFileSync(tmpPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
@@ -831,9 +839,9 @@ export function orderRunRecordFileNames(names) {
|
|
|
831
839
|
});
|
|
832
840
|
}
|
|
833
841
|
|
|
834
|
-
export function readRunRecordsForTarget(baseDir) {
|
|
842
|
+
export function readRunRecordsForTarget(baseDir, runtimeRelPath = RUNTIME_DIR_REL_PATH) {
|
|
835
843
|
try {
|
|
836
|
-
const dir =
|
|
844
|
+
const dir = runRecordsDir(baseDir, runtimeRelPath);
|
|
837
845
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
838
846
|
const names = orderRunRecordFileNames(readdirSync(dir).filter((name) => RUN_RECORD_FILE_NAME_PATTERN.test(name)))
|
|
839
847
|
.slice(0, RUN_RECORDS_SCAN_LIMIT);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// history, and which are not. Three kinds share the directory:
|
|
3
3
|
//
|
|
4
4
|
// 1. The readback bundle (build-context, assembly-report, doctor-output,
|
|
5
|
-
// qa-verdict
|
|
5
|
+
// qa-verdict, at the top level or under funnels/<route>/ for a project's
|
|
6
|
+
// second and later funnels) — current readback truth; docs/migration-sidecar-bundle.md
|
|
6
7
|
// names it and `bundle check` validates it. Committed by design.
|
|
7
8
|
// 2. Handoff inputs and agent context (input/, theme/, agent-context/,
|
|
8
9
|
// setup-handoff.json) — durable build handoff. Committed by design.
|
|
@@ -28,6 +29,9 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
28
29
|
".campaign-runtime/agent-deviations.jsonl",
|
|
29
30
|
".campaign-runtime/workflow-findings.jsonl",
|
|
30
31
|
".campaign-runtime/run-records/",
|
|
32
|
+
// A project's second and later funnels keep their Run Records under their
|
|
33
|
+
// own runtime directory (src/campaign-workspace.mjs).
|
|
34
|
+
".campaign-runtime/funnels/*/run-records/",
|
|
31
35
|
".campaign-runtime/fetched-specs/",
|
|
32
36
|
".campaign-runtime/progress/",
|
|
33
37
|
".campaign-runtime/polish-evidence/",
|
|
@@ -47,7 +51,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
47
51
|
// must gain it on the next setup command rather than on the next accidental
|
|
48
52
|
// commit. An entry an operator has placed anywhere else in the file counts as
|
|
49
53
|
// present.
|
|
50
|
-
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
|
|
54
|
+
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/", ".campaign-runtime/funnels/*/run-records/"];
|
|
51
55
|
|
|
52
56
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
53
57
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
@@ -7,6 +7,7 @@ import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
|
7
7
|
import { resolve, relative, dirname, posix, sep } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { isUtf8 } from 'node:buffer';
|
|
10
|
+
import { singleLineFragment } from './text-safety.mjs';
|
|
10
11
|
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
11
12
|
const version = value => {
|
|
12
13
|
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value))
|
|
@@ -361,6 +362,19 @@ function frontmatterScripts(text) {
|
|
|
361
362
|
// linePos is 1-based within the YAML block, which starts on the file's line 2.
|
|
362
363
|
return { scripts: scripts.items.map(item => ({ value: item.value, line: lineCounter.linePos(item.range[0]).line + 1 })) };
|
|
363
364
|
}
|
|
365
|
+
const INVENTORY_MAX_BYTES = 256 * 1024 * 1024;
|
|
366
|
+
const INVENTORY_DETAIL_MAX = 200;
|
|
367
|
+
|
|
368
|
+
// A missing git already fails at rev-parse, above the inventory.
|
|
369
|
+
export function inventoryFailure(error) {
|
|
370
|
+
if (error.code === 'ENOBUFS') return `the tracked-file list under --scope is larger than ${INVENTORY_MAX_BYTES / 1024 / 1024} MiB; narrow --scope.`;
|
|
371
|
+
const stderr = typeof error.stderr === 'string' ? error.stderr : error.stderr?.toString() ?? '';
|
|
372
|
+
const detail = singleLineFragment(stderr.trim().split('\n')[0], `git exited ${error.status ?? error.signal ?? error.code ?? 'abnormally'} with no message`);
|
|
373
|
+
const chars = Array.from(detail);
|
|
374
|
+
const bounded = chars.length > INVENTORY_DETAIL_MAX ? `${chars.slice(0, INVENTORY_DETAIL_MAX).join('')}…` : detail;
|
|
375
|
+
return `${bounded.replace(/\.$/, '')}.`;
|
|
376
|
+
}
|
|
377
|
+
|
|
364
378
|
export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
|
|
365
379
|
version(targetSdkVersion);
|
|
366
380
|
if (!Array.isArray(scope) || !scope.length)
|
|
@@ -375,9 +389,14 @@ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVers
|
|
|
375
389
|
if (rootIdentity.dev !== targetIdentity.dev || rootIdentity.ino !== targetIdentity.ino)
|
|
376
390
|
throw new Error('--target must be the Git repository root.');
|
|
377
391
|
const { value: manifest, evidence } = readStorageManifest(manifestPath);
|
|
392
|
+
// Inventory only the scope: a whole-repository list can pass Node's 1 MiB
|
|
393
|
+
// default buffer on a large repository (#717). Scopes are literal paths.
|
|
378
394
|
let tracked;
|
|
379
|
-
try {
|
|
380
|
-
|
|
395
|
+
try {
|
|
396
|
+
tracked = execFileSync('git', ['--literal-pathspecs', '-C', root, 'ls-files', '-z', '--', ...scope], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: INVENTORY_MAX_BYTES }).split('\0').filter(Boolean);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw new Error(`Cannot inventory tracked Git source files: ${inventoryFailure(error)}`);
|
|
399
|
+
}
|
|
381
400
|
for (const item of scope)
|
|
382
401
|
if (!tracked.some(p => inside(p, item)))
|
|
383
402
|
throw new Error(`Scope has no tracked files: ${item}`);
|
|
@@ -559,6 +559,20 @@ function addSpecHints(mapping, page) {
|
|
|
559
559
|
if (variantLabels) mapping.variant_labels = variantLabels;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
+
/**
|
|
563
|
+
* The page-kit projection of every page in `specPages` (a Map of page id to
|
|
564
|
+
* projection), as intake projects a mapped page: target path, public route,
|
|
565
|
+
* page_type and the frontmatter links to the other pages. `record spec`
|
|
566
|
+
* re-projects with it when routes move or, after handoff, when pages are
|
|
567
|
+
* added or removed (#531, #721); every page is re-projected because a page's
|
|
568
|
+
* next_url and decline_url read the routes of the pages it links to.
|
|
569
|
+
*/
|
|
570
|
+
export function projectPageKitTargets({ specPages = [], publicRouteSlug, outputDir }) {
|
|
571
|
+
const pages = Array.isArray(specPages) ? specPages : [];
|
|
572
|
+
const pageById = new Map(pages.map((page) => [page.id, page]));
|
|
573
|
+
return new Map(pages.map((page) => [page.id, pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages: pages })]));
|
|
574
|
+
}
|
|
575
|
+
|
|
562
576
|
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
|
|
563
577
|
const specRoute = publicRouteForPage(page);
|
|
564
578
|
const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
|
|
@@ -707,7 +721,7 @@ function normalizeRootedRoute(value) {
|
|
|
707
721
|
return normalized ? `/${normalized}` : "/";
|
|
708
722
|
}
|
|
709
723
|
|
|
710
|
-
function cpkPageTypeForSpecType(type) {
|
|
724
|
+
export function cpkPageTypeForSpecType(type) {
|
|
711
725
|
if (type === "presell" || type === "landing") return "product";
|
|
712
726
|
if (type === "thankyou" || type === "receipt") return "receipt";
|
|
713
727
|
if (type === "upsell" || type === "downsell") return "upsell";
|