@nextcommerce/campaigns-os 1.52.0 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/agents/claude/CLAUDE.md +2 -0
  3. package/agents/codex/AGENTS.md +2 -0
  4. package/agents/copilot/copilot-instructions.md +2 -0
  5. package/agents/cursor/campaigns-os.mdc +2 -0
  6. package/compatibility.json +1 -1
  7. package/contracts/effects.v1.json +201 -12
  8. package/contracts/release-ledger.json +327 -0
  9. package/contracts/supported-surface.json +2 -2
  10. package/docs/brand-theme-bridge.md +2 -0
  11. package/docs/build-packet.md +56 -15
  12. package/docs/campaign-build-brief.md +48 -17
  13. package/docs/effects.md +8 -0
  14. package/docs/local-setup.md +1 -1
  15. package/docs/orientation-contract-reference.md +1 -1
  16. package/docs/polish-evidence.md +2 -0
  17. package/docs/progress-snapshots.md +2 -4
  18. package/docs/qa-and-test-orders.md +2 -0
  19. package/docs/readback.md +19 -2
  20. package/docs/runtime-readiness.md +1 -1
  21. package/docs/skills-revision.md +10 -10
  22. package/package.json +1 -1
  23. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  24. package/skills/campaign-readback-classification/SKILL.md +3 -3
  25. package/skills/campaign-run-evidence/SKILL.md +3 -3
  26. package/skills/contribution-intake/SKILL.md +3 -3
  27. package/skills/next-campaigns-build/SKILL.md +5 -3
  28. package/skills/next-campaigns-os/SKILL.md +10 -19
  29. package/skills/next-campaigns-os/references/session-intake.md +10 -6
  30. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  31. package/skills/next-campaigns-polish/SKILL.md +7 -5
  32. package/skills/next-campaigns-qa/SKILL.md +5 -3
  33. package/skills.json +10 -10
  34. package/src/brand-theme.mjs +21 -20
  35. package/src/brief-summary.mjs +275 -0
  36. package/src/build-brief.mjs +167 -15
  37. package/src/cli.mjs +160 -112
  38. package/src/contrast.mjs +649 -0
  39. package/src/design-source-publication.mjs +110 -3
  40. package/src/doctor/checks.mjs +129 -14
  41. package/src/doctor/next-step.mjs +15 -6
  42. package/src/input-currency.mjs +394 -0
  43. package/src/input-refresh.mjs +708 -0
  44. package/src/invocation.mjs +3 -1
  45. package/src/polish-browser.mjs +503 -201
  46. package/src/polish-deadline.mjs +7 -0
  47. package/src/polish-node.mjs +326 -46
  48. package/src/polish-readability.mjs +828 -0
  49. package/src/progress-node.mjs +15 -3
  50. package/src/qa-browser.mjs +131 -84
  51. package/src/qa-node.mjs +14 -3
  52. package/src/qc-accept.mjs +44 -8
  53. package/src/qc-check-registry.mjs +15 -7
  54. package/src/qc-results.mjs +326 -34
  55. package/src/readback.mjs +58 -10
  56. package/src/stage-ledger.mjs +235 -44
  57. package/src/stage-record.mjs +119 -26
  58. package/src/tooling-setup.mjs +1 -1
@@ -4,8 +4,11 @@
4
4
  //
5
5
  // Storage per leg:
6
6
  // - doctor: derived.qc_results[], recomputed from built HTML on every run;
7
- // - polish: stages.polish.evidence.visual_review.media_weight, re-derived from
8
- // its cells and cross-checked against the page_load capture;
7
+ // - polish: one package-owned record per stages.polish.evidence.visual_review
8
+ // key (POLISH_RECORDS): media_weight, re-derived from its cells and
9
+ // cross-checked against the page_load capture, and readability, whose
10
+ // elements are re-derived through the shared contrast helper and whose
11
+ // route set is recomputed from the built output;
9
12
  // - qa: stages.qa.evidence.qc_results[] beside qc_build_fingerprint,
10
13
  // re-derived from the qc.* assertions of the full QA verdict.
11
14
  //
@@ -20,12 +23,13 @@ import { isAbsolute, relative, resolve } from "node:path";
20
23
 
21
24
  import { campaignSidecarPaths } from "./campaign-workspace.mjs";
22
25
  import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
26
+ import { contrastToolkit } from "./contrast.mjs";
23
27
  import { sameFile } from "./fs-identity.mjs";
24
28
  import { buildPolishCaptureIntegrity, canonicalJson, captureOrigin, mediaFetchedResources } from "./polish-capture.mjs";
25
- import { currentBuildFingerprint } from "./polish-gate.mjs";
29
+ import { currentBuildFingerprint, currentSourcePackageMaterialFingerprint } from "./polish-gate.mjs";
26
30
  import { sidecarPathForPacket } from "./qa-sidecar.mjs";
27
31
  import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
28
- import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg } from "./qc-check-registry.mjs";
32
+ import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg, qcChecksForRecord } from "./qc-check-registry.mjs";
29
33
  import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
30
34
 
31
35
  export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
@@ -184,10 +188,14 @@ function qaRederiver(check, { qcStandIns, rederivers }) {
184
188
  return entry;
185
189
  }
186
190
 
187
- export function resolvePolishRules({ qcStandIns, rederivers } = {}) {
188
- const standIn = qcStandIns?.polish;
189
- if (isPlainObject(standIn)) return { status: "loaded", rules: standIn };
190
- const entry = (rederivers || loadedQcRederivers())?.["media.weight"];
191
+ // The rules of the record a Polish check is read from. A stand-in is
192
+ // `qcStandIns.polish[check]`, or `qcStandIns.polish` itself for the
193
+ // media_weight checks.
194
+ export function resolvePolishRules({ check = "media.weight", qcStandIns, rederivers } = {}) {
195
+ const standIn = isPlainObject(qcStandIns?.polish?.[check]) ? qcStandIns.polish[check]
196
+ : QC_CHECK_REGISTRY[check]?.record === "media_weight" && isPlainObject(qcStandIns?.polish) ? qcStandIns.polish : null;
197
+ if (standIn) return { status: "loaded", rules: standIn };
198
+ const entry = (rederivers || loadedQcRederivers())?.[check];
191
199
  if (!entry) return { status: "unresolved" };
192
200
  if (entry.status !== "loaded") return entry;
193
201
  const rules = entry.rederive;
@@ -307,12 +315,18 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
307
315
  }
308
316
  }
309
317
 
310
- export function readQaResults({ stageEvidence, stage = null, fullVerdict = null, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
318
+ // `qaCurrency` is QA's read-time input currency
319
+ // (derived.input_currency.stages.qa). While QA is owed again or its inputs
320
+ // cannot be confirmed, every row that reproduces reads stale_binding, as a
321
+ // row bound to another build does; a row that fails reproduction still reads
322
+ // evidence_not_reproducible first.
323
+ export function readQaResults({ stageEvidence, stage = null, fullVerdict = null, currentBuild = null, qcStandIns = null, rederivers = null, qaCurrency = null } = {}) {
311
324
  const stored = Array.isArray(stageEvidence?.qc_results) ? stageEvidence.qc_results : null;
312
325
  if (!stored) return [];
313
326
  const verdictOk = qaFullVerdictProblem(fullVerdict, stage) === null;
314
327
  const measuredAt = verdictOk ? (validTime(fullVerdict.completed_at) ? fullVerdict.completed_at : fullVerdict.started_at) : null;
315
- const bound = isNonEmptyString(currentBuild) && stageEvidence.qc_build_fingerprint === currentBuild;
328
+ const inputsCurrent = qaCurrency !== "owed" && qaCurrency !== "unknown";
329
+ const bound = inputsCurrent && isNonEmptyString(currentBuild) && stageEvidence.qc_build_fingerprint === currentBuild;
316
330
 
317
331
  // Rows pair with assertions by evidence.qc.result_id, never by assertion id.
318
332
  // A passing verdict holds only well-formed QC evidence (qaFullVerdictProblem),
@@ -400,14 +414,17 @@ function readQaRow(row, { verdictOk, assertion, measuredAt, qcStandIns, rederive
400
414
  }
401
415
 
402
416
  // ---------------------------------------------------------------------------
403
- // Polish reader site (media_weight against its page_load capture)
417
+ // Polish reader sites (media_weight against its page_load capture, and
418
+ // readability)
404
419
 
405
- // Unkeyed tamper evidence over every field but `integrity`, the canonical-JSON
406
- // + sha256 pattern of buildPolishCaptureIntegrity.
407
- export function mediaWeightIntegrity(record) {
420
+ // Unkeyed tamper evidence of a package-owned Polish record over every field
421
+ // but `integrity`, the canonical-JSON + sha256 pattern of
422
+ // buildPolishCaptureIntegrity.
423
+ export function polishRecordIntegrity(record) {
408
424
  const { integrity: _ignored, ...rest } = record || {};
409
425
  return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
410
426
  }
427
+ export const mediaWeightIntegrity = polishRecordIntegrity;
411
428
 
412
429
  const inVocabulary = (vocabulary, field, value) => Array.isArray(vocabulary?.[field]) && vocabulary[field].includes(value);
413
430
  const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
@@ -809,25 +826,34 @@ function captureBindingProblem(capture, subject, currentBuild) {
809
826
 
810
827
  const PAGE_NOT_CAPTURED = "page_not_captured";
811
828
 
812
- export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStandIns = null, rederivers = null } = {}) {
829
+ // Built output that no longer matches the recorded build: doctor's
830
+ // derived.build_output_fingerprint, whose status is pass only when the
831
+ // output under _site/<slug>/ is the one `record build` recorded. Any other
832
+ // value, null included, is drift. A caller that passes no value (undefined)
833
+ // reads without the output check.
834
+ const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
835
+
836
+ export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
813
837
  if (record === undefined || record === null) return [];
838
+ const checks = POLISH_RECORDS.media_weight.checks;
814
839
  const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
815
840
  const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
816
841
  const unreproduced = (subject) => unreproducedRow({ subject, check: subject.check }, E, { leg: "polish", measuredAt });
817
842
  const cellResult = (check, route, viewport) => unreproduced({ check, page: route, viewport, key: "cell" });
818
- // A failed cell: one result per subject it lists, and one per Polish check
819
- // it lists no subject for, so a failed cell, even one listing nothing, is
820
- // never silent.
843
+ // A failed cell: one result per subject it lists, and one per media_weight
844
+ // check it lists no subject for, so a failed cell, even one listing
845
+ // nothing, is never silent.
821
846
  const failCell = (cell) => {
822
847
  const subjects = cellSubjects(cell);
823
848
  return [
824
849
  ...subjects.map(unreproduced),
825
- ...POLISH_CHECKS.filter((check) => !subjects.some((subject) => subject.check === check)).map((check) => cellResult(check, cell?.route ?? null, cell?.viewport ?? null)),
850
+ ...checks.filter((check) => !subjects.some((subject) => subject.check === check)).map((check) => cellResult(check, cell?.route ?? null, cell?.viewport ?? null)),
826
851
  ];
827
852
  };
828
853
  // A record-level failure: every listed cell fails, and every route ×
829
854
  // viewport that the record or page_load declares or captured but no cell
830
- // lists gets one result per Polish check, so a missing route is never silent.
855
+ // lists gets one result per media_weight check, so a missing route is never
856
+ // silent.
831
857
  const failAll = () => {
832
858
  const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
833
859
  const unlisted = new Map();
@@ -840,11 +866,11 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
840
866
  for (const entry of Array.isArray(pageLoad?.measurement?.missing) ? pageLoad.measurement.missing : []) note(entry?.route, entry?.viewport);
841
867
  const rows = [
842
868
  ...cells.flatMap(failCell),
843
- ...[...unlisted.values()].flatMap(({ route, viewport }) => POLISH_CHECKS.map((check) => cellResult(check, route, viewport))),
869
+ ...[...unlisted.values()].flatMap(({ route, viewport }) => checks.map((check) => cellResult(check, route, viewport))),
844
870
  ];
845
871
  return rows.length ? rows : [unreproduced({ check: "media.weight", page: null, key: "media_weight" })];
846
872
  };
847
- const resolved = resolvePolishRules({ qcStandIns, rederivers });
873
+ const resolved = resolvePolishRules({ check: checks[0], qcStandIns, rederivers });
848
874
  if (resolved.status === "missing") return [];
849
875
  if (resolved.status !== "loaded") return failAll();
850
876
  const { rules } = resolved;
@@ -887,11 +913,13 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
887
913
  || captureByKey.size !== gridKeys.length
888
914
  || !gridKeys.every((key) => captureByKey.has(key) && cellKeys.includes(key))) return failAll();
889
915
 
890
- // Both enclosing subjects are bound to the current build; an absent, null
891
- // or other build on either reads stale_binding.
916
+ // Both enclosing subjects are bound to the current build, and the built
917
+ // output is still that build's; an absent, null or other build on either,
918
+ // or output drift, reads stale_binding.
892
919
  const recordBound = isNonEmptyString(currentBuild)
893
920
  && record.subject.build_fingerprint === currentBuild
894
- && pageLoad.subject.build_fingerprint === currentBuild;
921
+ && pageLoad.subject.build_fingerprint === currentBuild
922
+ && !outputDrifted(buildOutput);
895
923
  const results = [];
896
924
  // A spec page with no source mapping: one result per 1.3 check and viewport,
897
925
  // unexercised / page_not_captured; keyed "cell" on its public route, or
@@ -902,7 +930,7 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
902
930
  ];
903
931
  for (const { page, key } of uncaptured) {
904
932
  for (const viewport of record.subject.viewports) {
905
- for (const check of POLISH_CHECKS) {
933
+ for (const check of checks) {
906
934
  const subject = { check, page, viewport, key };
907
935
  const row = unreproducedRow({ subject, check }, PAGE_NOT_CAPTURED, { leg: "polish", measuredAt });
908
936
  results.push(recordBound ? row : staleRow(row));
@@ -926,7 +954,7 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
926
954
  // Every derived subject is one the cell lists (cellSubjects), exactly:
927
955
  // its own page, viewport and key, and no other field.
928
956
  const own = new Set(cellSubjects(cell).map((subject) => canonicalJson(subject)));
929
- if (!Array.isArray(derived) || !derived.every((item) => ["media.weight", "media.oversize"].includes(item?.check) && validDerived(item, item.check) && own.has(canonicalJson(item.subject)))) {
957
+ if (!Array.isArray(derived) || !derived.every((item) => checks.includes(item?.check) && validDerived(item, item.check) && own.has(canonicalJson(item.subject)))) {
930
958
  results.push(...failCell(cell));
931
959
  continue;
932
960
  }
@@ -954,6 +982,234 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
954
982
  return results;
955
983
  }
956
984
 
985
+ // The readability record's subject fields besides build_fingerprint.
986
+ const READABILITY_SUBJECT_FIELDS = Object.freeze(["source_package_material_fingerprint", "campaign_slug", "route_source", "routes", "viewports"]);
987
+ const READABILITY_DERIVED_FIELDS = Object.freeze(["fg_srgb", "bg_srgb", "gamut_clipped", "ratio", "size_class", "required"]);
988
+ const contrast = contrastToolkit();
989
+ const canonicalTime = (value) => validTime(value) && new Date(value).toISOString() === value;
990
+
991
+ // Every derived field of a stored element re-derives exactly from its raw
992
+ // computed colours and font through the shared contrast helper.
993
+ function readabilityElementReproduces(element) {
994
+ let derived;
995
+ try {
996
+ derived = contrast.deriveElementMeasurement({
997
+ fg_raw: element.fg_raw,
998
+ fill_raw: element.fill_raw,
999
+ bg_layers_raw: element.bg_layers_raw,
1000
+ font_size_px: element.font_size_px,
1001
+ font_weight: element.font_weight,
1002
+ });
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ return READABILITY_DERIVED_FIELDS.every((field) => Object.hasOwn(element, field) && sameJson(element[field], derived[field]));
1007
+ }
1008
+
1009
+ // A crop reference resolves when its file under the target repo holds bytes
1010
+ // whose sha256 is the one recorded.
1011
+ function readabilityCropResolves(ref, targetRepo) {
1012
+ if (!isNonEmptyString(targetRepo) || !isPlainObject(ref) || !isNonEmptyString(ref.path) || !isNonEmptyString(ref.sha256)) return false;
1013
+ try {
1014
+ const file = resolve(targetRepo, ref.path);
1015
+ if (!statSync(file).isFile()) return false;
1016
+ return `sha256:${createHash("sha256").update(readFileSync(file)).digest("hex")}` === ref.sha256;
1017
+ } catch {
1018
+ return false;
1019
+ }
1020
+ }
1021
+
1022
+ // The readability record (contract 2.4 Result rules), read in precedence
1023
+ // order:
1024
+ // 1. the record and cell reader checks (producer, schema, helper version,
1025
+ // integrity, canonical measured_at, the constant thresholds and limits,
1026
+ // the fixed producer values, the vocabulary, the exact routes × viewports
1027
+ // grid, every element re-derived through the shared helper, and each
1028
+ // shared cell's page_load integrity) → evidence_not_reproducible;
1029
+ // 2. the binding (build, source package, and doctor's output fingerprint)
1030
+ // → every row stale_binding, before any route-set result;
1031
+ // 3. the built route set, recomputed from _site/<slug>/: a captured route
1032
+ // the record lacks, or one over the route cap, reads page_not_captured;
1033
+ // a recorded route outside it, or different uncaptured routes or
1034
+ // enumeration cap, → evidence_not_reproducible;
1035
+ // 4. the rows READABILITY_QC_RULES.evaluate derives from each cell.
1036
+ // `campaignSlug` is the packet's slug; a missing one never matches.
1037
+ export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = null, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
1038
+ if (record === undefined || record === null) return [];
1039
+ const [check] = POLISH_RECORDS.readability.checks;
1040
+ const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
1041
+ const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
1042
+ const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
1043
+ const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
1044
+ const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
1045
+ let rules = null;
1046
+ // A failed cell: one result per row the cell names and its `cell` row, so
1047
+ // a failed cell is never silent and an accept of one of its rows lapses
1048
+ // rather than orphans. The keys come from rules.rowKeys, which reads
1049
+ // neither the record's thresholds nor any field the checks reject.
1050
+ const failCell = (cell) => {
1051
+ let named = [];
1052
+ try {
1053
+ named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
1054
+ } catch {
1055
+ named = [];
1056
+ }
1057
+ const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
1058
+ return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
1059
+ };
1060
+ // A record-level failure: every listed cell fails; every route the subject
1061
+ // or a cell names, at every recorded or fixed viewport, that no cell lists
1062
+ // gets its `cell` row; and the uncaptured routes and the enumeration cap
1063
+ // keep the rows they would read.
1064
+ const failAll = () => {
1065
+ const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
1066
+ const routes = [...(Array.isArray(record?.subject?.routes) ? record.subject.routes : []), ...cells.map((cell) => cell.route)].filter(isNonEmptyString);
1067
+ const uncaptured = (Array.isArray(record?.uncaptured_routes) ? record.uncaptured_routes : []).filter(isNonEmptyString);
1068
+ let fixed = [];
1069
+ try {
1070
+ fixed = Array.isArray(rules?.viewports) ? rules.viewports : [];
1071
+ } catch {
1072
+ fixed = [];
1073
+ }
1074
+ const viewports = [...(Array.isArray(record?.subject?.viewports) ? record.subject.viewports : []), ...fixed].filter(isNonEmptyString);
1075
+ const grid = [...new Set([...routes, ...uncaptured])].flatMap((route) => [...new Set(viewports)].map((viewport) => ({ route, viewport })));
1076
+ const rows = [
1077
+ ...cells.flatMap(failCell),
1078
+ ...grid.filter(({ route, viewport }) => !listed.has(cellKey(route, viewport))).map(({ route, viewport }) => rowOf(subjectOf(route, viewport, "cell"), E)),
1079
+ ...(record?.route_enumeration_capped === true ? [rowOf(subjectOf(null, null, "scope"), E)] : []),
1080
+ ];
1081
+ return unique(rows.length ? rows : [rowOf(subjectOf(null, null, "readability"), E)]);
1082
+ };
1083
+ const evaluate = (cell, cropAvailable) => {
1084
+ try {
1085
+ return rules.evaluate(cell, record.thresholds, { cropAvailable });
1086
+ } catch {
1087
+ return null;
1088
+ }
1089
+ };
1090
+ const resolved = resolvePolishRules({ check, qcStandIns, rederivers });
1091
+ if (resolved.status === "missing") return [];
1092
+ if (resolved.status !== "loaded") return failAll();
1093
+ ({ rules } = resolved);
1094
+ if (typeof rules.routes !== "function" || typeof rules.cellShapeOk !== "function") return failAll();
1095
+
1096
+ // 1. Record level.
1097
+ const subject = record?.subject;
1098
+ const viewports = rules.viewports;
1099
+ if (!isPlainObject(record)
1100
+ || record.schema_version !== rules.schema_version
1101
+ || record.performed_by !== QC_PRODUCERS.polish
1102
+ || record.helper_version !== rules.helper_version
1103
+ || record.helper_version !== contrast.version
1104
+ || !measuredAt
1105
+ || record.integrity !== polishRecordIntegrity(record)
1106
+ || !sameJson(record.thresholds, rules.thresholds)
1107
+ || !sameJson(record.limits, rules.limits)
1108
+ || !hasExactly(subject, READABILITY_SUBJECT_FIELDS)
1109
+ || !(subject.source_package_material_fingerprint === null || isNonEmptyString(subject.source_package_material_fingerprint))
1110
+ || !sameJson(subject.viewports, viewports)
1111
+ || subject.route_source !== rules.route_source
1112
+ || !isNonEmptyString(campaignSlug)
1113
+ || subject.campaign_slug !== campaignSlug
1114
+ || !uniqueStrings(subject.routes)
1115
+ || !Array.isArray(record.uncaptured_routes)
1116
+ || !(record.uncaptured_routes.length === 0 || uniqueStrings(record.uncaptured_routes))
1117
+ || record.uncaptured_routes.some((route) => subject.routes.includes(route))
1118
+ || typeof record.route_enumeration_capped !== "boolean"
1119
+ || !Array.isArray(record.cells)
1120
+ || cells.length !== record.cells.length
1121
+ || !cells.every((cell) => rules.cellShapeOk(cell))) return failAll();
1122
+ // The cell set is exactly routes × viewports: none missing, none added,
1123
+ // none twice, every cell's route and viewport in the subject.
1124
+ const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
1125
+ const cellKeys = cells.map((cell) => cellKey(cell.route, cell.viewport));
1126
+ if (new Set(cellKeys).size !== cellKeys.length
1127
+ || cellKeys.length !== gridKeys.length
1128
+ || !cells.every((cell) => subject.routes.includes(cell.route) && subject.viewports.includes(cell.viewport))) return failAll();
1129
+
1130
+ // Cell level: every element re-derives, and a shared cell's page_load
1131
+ // integrity is that route × viewport's capture integrity.
1132
+ const captures = Array.isArray(pageLoad?.captures) ? pageLoad.captures : [];
1133
+ const cellReadable = (cell) => {
1134
+ if (!cell.elements.every(readabilityElementReproduces)) return false;
1135
+ if (cell.page_load_integrity === null) return true;
1136
+ const matching = captures.filter((capture) => capture?.subject?.requested_route === cell.route && capture?.subject?.viewport === cell.viewport);
1137
+ if (matching.length !== 1) return false;
1138
+ const [capture] = matching;
1139
+ return isPlainObject(capture.integrity)
1140
+ && sameJson(buildPolishCaptureIntegrity(capture), capture.integrity)
1141
+ && capture.integrity.projection_fingerprint === cell.page_load_integrity;
1142
+ };
1143
+
1144
+ // 2. Binding.
1145
+ const bound = isNonEmptyString(currentBuild)
1146
+ && subject.build_fingerprint === currentBuild
1147
+ && subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
1148
+ && !outputDrifted(buildOutput ?? null);
1149
+
1150
+ // 3. The recomputed route set, read only when the output is the build's.
1151
+ const results = [];
1152
+ const notCaptured = (route) => subject.viewports.map((viewport) => rowOf(subjectOf(route, viewport, "cell"), PAGE_NOT_CAPTURED));
1153
+ const scopeRow = () => rowOf(subjectOf(null, null, "scope"), "route_enumeration_capped");
1154
+ if (bound) {
1155
+ let built;
1156
+ try {
1157
+ built = rules.routes(targetRepo, campaignSlug);
1158
+ } catch {
1159
+ built = null;
1160
+ }
1161
+ if (!isPlainObject(built) || built.ok !== true
1162
+ || !subject.routes.every((route) => built.routes.includes(route))
1163
+ || !sameJson(record.uncaptured_routes, built.uncaptured_routes)
1164
+ || record.route_enumeration_capped !== built.route_enumeration_capped) return failAll();
1165
+ for (const route of built.routes.filter((route) => !subject.routes.includes(route))) results.push(...notCaptured(route));
1166
+ }
1167
+ for (const route of record.uncaptured_routes) results.push(...notCaptured(route).map((row) => (bound ? row : staleRow(row))));
1168
+ if (record.route_enumeration_capped) results.push(bound ? scopeRow() : staleRow(scopeRow()));
1169
+
1170
+ // 4. The rows.
1171
+ const cropAvailable = (ref) => rules.cropRefShapeOk?.(ref) === true && readabilityCropResolves(ref, targetRepo);
1172
+ for (const cell of cells) {
1173
+ if (!cellReadable(cell)) {
1174
+ results.push(...failCell(cell));
1175
+ continue;
1176
+ }
1177
+ const derived = evaluate(cell, cropAvailable);
1178
+ // Every derived subject is the cell's own page and viewport, each key once.
1179
+ const keys = Array.isArray(derived) ? derived.map((item) => item?.subject?.key) : [];
1180
+ if (!Array.isArray(derived) || new Set(keys).size !== keys.length || !derived.every((item) => item?.check === check
1181
+ && validDerived(item, check)
1182
+ && hasExactly(item.subject, ["check", "page", "viewport", "key"])
1183
+ && item.subject.page === cell.route
1184
+ && item.subject.viewport === cell.viewport
1185
+ && isNonEmptyString(item.subject.key))) {
1186
+ results.push(...failCell(cell));
1187
+ continue;
1188
+ }
1189
+ for (const item of derived) {
1190
+ const members = item.members ?? [];
1191
+ const row = {
1192
+ schema: QC_RESULT_SCHEMA,
1193
+ id: qcResultId(item.subject),
1194
+ check,
1195
+ leg: "polish",
1196
+ result: item.result,
1197
+ reason_code: item.result === "pass" ? null : item.reason_code,
1198
+ subject: item.subject,
1199
+ state_fingerprint: qcStateFingerprint({ subject: item.subject, state: item.state }),
1200
+ observation: isPlainObject(item.observation) ? item.observation : {},
1201
+ members,
1202
+ accept_eligible: item.accept_eligible && qcAcceptEligible(item.result, members),
1203
+ coverage: item.coverage ?? { observed: 1, expected: 1, limits: [] },
1204
+ measured_at: measuredAt,
1205
+ producer: QC_PRODUCERS.polish,
1206
+ };
1207
+ results.push(bound ? row : staleRow(row));
1208
+ }
1209
+ }
1210
+ return results;
1211
+ }
1212
+
957
1213
  // ---------------------------------------------------------------------------
958
1214
  // Every current result, per leg, and the silence rule
959
1215
 
@@ -961,6 +1217,13 @@ const QA_ALWAYS = Object.freeze(["tracking.url", "tracking.order", "tracking.tag
961
1217
  const QA_CONTENT = Object.freeze(["content_param"]);
962
1218
  const QA_POLICY = Object.freeze(["policy.presence", "policy.availability"]);
963
1219
  const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
1220
+ // The Polish dispatch: each package-owned visual_review record, the checks
1221
+ // read from it, and its reader. Each record is read, and its silence judged,
1222
+ // on its own.
1223
+ const POLISH_RECORDS = Object.freeze({
1224
+ media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
1225
+ readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
1226
+ });
964
1227
  const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
965
1228
 
966
1229
  // Applicability is decided without the leg running: the tracking checks
@@ -1014,15 +1277,34 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
1014
1277
  for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
1015
1278
  } else {
1016
1279
  const visual = polishStage.evidence?.visual_review;
1017
- const captured = isPlainObject(visual?.page_load) && visual.media_weight != null;
1018
- const failed = captured && resolvePolishRules({ qcStandIns, rederivers }).status === "failed";
1019
- for (const check of POLISH_CHECKS) {
1020
- if (!hasRow("polish", check)) entries.push(silence("polish", check, silentReason(failed)));
1280
+ for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
1281
+ const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
1282
+ for (const check of checks) {
1283
+ if (hasRow("polish", check)) continue;
1284
+ const failed = captured && resolvePolishRules({ check, qcStandIns, rederivers }).status === "failed";
1285
+ entries.push(silence("polish", check, silentReason(failed)));
1286
+ }
1021
1287
  }
1022
1288
  }
1023
1289
  return entries;
1024
1290
  }
1025
1291
 
1292
+ // The campaign slug the Polish records bind to: the packet's, read from
1293
+ // packetPath; with no readable packet, the Assembly Report's (which capture
1294
+ // requires to equal the packet's). Null when neither names one.
1295
+ function packetCampaignSlug({ packetPath, report }) {
1296
+ if (isNonEmptyString(packetPath)) {
1297
+ try {
1298
+ const slug = JSON.parse(readFileSync(packetPath, "utf8"))?.campaign?.public_route_slug;
1299
+ return isNonEmptyString(slug) ? slug : null;
1300
+ } catch {
1301
+ // No readable packet: fall through to the report.
1302
+ }
1303
+ }
1304
+ const slug = report?.identity?.public_route_slug;
1305
+ return isNonEmptyString(slug) ? slug : null;
1306
+ }
1307
+
1026
1308
  // Every current QC result `next` and `checkpoint accept` judge, read from
1027
1309
  // data already on disk: the doctor run, the Assembly Report, and the full QA
1028
1310
  // verdict the QA stage names. No request is made.
@@ -1030,9 +1312,18 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1030
1312
  const currentBuild = currentBuildFingerprint(report);
1031
1313
  const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
1032
1314
  const visual = report?.stages?.polish?.evidence?.visual_review;
1033
- const polishResults = isPlainObject(visual) && visual.media_weight != null
1034
- ? readMediaWeight({ record: visual.media_weight, pageLoad: visual.page_load, currentBuild, qcStandIns, rederivers })
1035
- : [];
1315
+ const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
1316
+ const polishContext = recorded.length ? {
1317
+ pageLoad: visual.page_load,
1318
+ currentBuild,
1319
+ currentSource: currentSourcePackageMaterialFingerprint(report),
1320
+ buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
1321
+ campaignSlug: packetCampaignSlug({ packetPath, report }),
1322
+ targetRepo,
1323
+ qcStandIns,
1324
+ rederivers,
1325
+ } : null;
1326
+ const polishResults = recorded.flatMap(([name, { read }]) => read({ ...polishContext, record: visual[name] }));
1036
1327
  const qaStage = report?.stages?.qa;
1037
1328
  const qaResults = Array.isArray(qaStage?.evidence?.qc_results)
1038
1329
  ? readQaResults({
@@ -1040,6 +1331,7 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
1040
1331
  stage: qaStage,
1041
1332
  fullVerdict: readQaFullVerdict({ stage: qaStage, targetRepo, packetPath, reportPath }),
1042
1333
  currentBuild,
1334
+ qaCurrency: doctor?.derived?.input_currency?.stages?.qa,
1043
1335
  qcStandIns,
1044
1336
  rederivers,
1045
1337
  })
package/src/readback.mjs CHANGED
@@ -8,7 +8,9 @@
8
8
  * most once, plus two fixed Git metadata files for the staleness comparison
9
9
  * (the nearest `.git` entry at the target or one of its ancestors — funnels are
10
10
  * usually subdirectories of their enclosing campaign repository — to locate the
11
- * Git directory, and that directory's `logs/HEAD` reflog). It writes nothing,
11
+ * Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
12
+ * and normalized Campaign Build Brief the Build Packet names, whose current
13
+ * content decides each stage's effective status. It writes nothing,
12
14
  * starts no process, and touches no network. That contract is why the CLI
13
15
  * exempts `readback` from lifecycle-journal capture the way it exempts doctor
14
16
  * inspection: a command declared read-only must not append a journal entry.
@@ -49,6 +51,9 @@ import { closeSync, fstatSync, openSync, readdirSync, readSync, statSync } from
49
51
  import { isAbsolute, join, resolve } from "node:path";
50
52
  import { fileURLToPath } from "node:url";
51
53
 
54
+ import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
55
+ import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
56
+
52
57
  export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
53
58
  export const MAX_GIT_METADATA_BYTES = 64 * 1024;
54
59
 
@@ -1268,22 +1273,52 @@ export function renderBlocker(blocker) {
1268
1273
  return `(non-string entry, shown as written) ${rendered}`;
1269
1274
  }
1270
1275
 
1271
- function renderStages(views, lines) {
1276
+ /**
1277
+ * The campaign's current inputs, read through the projected Build Packet: the
1278
+ * material of the CampaignSpec and of the normalized brief it names. A value
1279
+ * that cannot be read is null, and the stages that depend on it read unknown.
1280
+ */
1281
+ export function readbackInputs(views) {
1282
+ const packet = artifactData(views, "packet");
1283
+ if (packet === null) return { briefMaterial: null, specMaterial: null };
1284
+ return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
1285
+ }
1286
+
1287
+ // Superseded records shown per stage, newest last (the report keeps five).
1288
+ const HISTORY_RENDER_CAP = 5;
1289
+
1290
+ /**
1291
+ * Each stage at its effective status against `inputs` (readbackInputs): a
1292
+ * stage owed again by a brief or CampaignSpec change reads `required`, one
1293
+ * whose inputs cannot be confirmed reads `unknown`, and where that differs
1294
+ * from the record the line adds the recorded status and the reason. The
1295
+ * header carries the report's recorded status beside the one recomputed from
1296
+ * the effective statuses. Superseded records are listed only under the
1297
+ * "history (not current proof)" label.
1298
+ */
1299
+ function renderStages(views, lines, inputs = null) {
1272
1300
  const report = artifactData(views, "report");
1273
1301
  if (report === null) return;
1274
- lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}]`);
1302
+ const current = { briefMaterial: inputs?.briefMaterial ?? null, specMaterial: inputs?.specMaterial ?? null };
1303
+ const currency = assessInputCurrency({ report, ...current });
1304
+ const effectiveReport = deriveAssemblyReportSummary(report, current).status;
1305
+ lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}; effective: ${effectiveReport}]`);
1275
1306
  const stages = report.stages;
1276
1307
  if (isPlainObject(stages)) {
1277
1308
  for (const [name, stage] of Object.entries(stages)) {
1278
1309
  if (!isPlainObject(stage)) continue;
1279
1310
  const status = stage.status ?? "(no status recorded)";
1311
+ const effective = effectiveStageStatus(name, stage, currency);
1312
+ const shown = effective === stage.status
1313
+ ? status
1314
+ : `${effective.padEnd(10)} (recorded: ${status}; ${currency.reasons?.[name] ?? "status_unrecognized"})`;
1280
1315
  const counters = [];
1281
1316
  const blockers = stage.blockers;
1282
1317
  const warnings = stage.warnings;
1283
1318
  if (Array.isArray(blockers) && blockers.length) counters.push(`${blockers.length} blocker(s)`);
1284
1319
  if (Array.isArray(warnings) && warnings.length) counters.push(`${warnings.length} warning(s)`);
1285
1320
  const suffix = counters.length ? ` (${counters.join(", ")})` : "";
1286
- lines.push(` ${name.padEnd(14)} ${status}${suffix}`);
1321
+ lines.push(` ${name.padEnd(14)} ${shown}${suffix}`);
1287
1322
  if (Array.isArray(blockers) && blockers.length) {
1288
1323
  for (const blocker of blockers.slice(0, BLOCKER_RENDER_CAP)) {
1289
1324
  lines.push(` blocker: ${renderBlocker(blocker)}`);
@@ -1291,6 +1326,16 @@ function renderStages(views, lines) {
1291
1326
  const hidden = blockers.length - BLOCKER_RENDER_CAP;
1292
1327
  if (hidden > 0) lines.push(` ... and ${hidden} more blocker(s) recorded in the assembly report`);
1293
1328
  }
1329
+ const history = Array.isArray(stage.history) ? stage.history.filter(isPlainObject) : [];
1330
+ if (history.length) {
1331
+ lines.push(` history (not current proof): ${history.length} superseded record(s)`);
1332
+ for (const entry of history.slice(-HISTORY_RENDER_CAP)) {
1333
+ lines.push(
1334
+ ` ${oneLine(String(recorded(entry.archived_at)))} ${oneLine(String(recorded(entry.reason_code)))}: ` +
1335
+ `was ${oneLine(String(recorded(entry.status)))}${entry.archived_by ? ` (archived by ${oneLine(String(entry.archived_by))})` : ""}`,
1336
+ );
1337
+ }
1338
+ }
1294
1339
  }
1295
1340
  }
1296
1341
  lines.push("");
@@ -1496,8 +1541,10 @@ function renderFindings(views, lines) {
1496
1541
  * artifact table says nothing about how the packet was chosen. The
1497
1542
  * one-argument form keeps working: the artifact table shows each loaded
1498
1543
  * artifact's generated_at regardless of whether an assessment was supplied.
1544
+ * `inputs` is readbackInputs(views); when omitted, every input reads as not
1545
+ * readable, so a build, Polish or QA record reads unknown.
1499
1546
  */
1500
- export function projectReadback(views, staleness = null, packetSelection = null) {
1547
+ export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
1501
1548
  const lines = [
1502
1549
  "CAMPAIGNS OS RUN-ARTIFACT READBACK",
1503
1550
  "A read-only projection of this run's emitted artifacts. Campaigns OS",
@@ -1509,7 +1556,7 @@ export function projectReadback(views, staleness = null, packetSelection = null)
1509
1556
  renderStaleness(staleness, lines);
1510
1557
  renderArtifactTable(views, lines, packetSelection);
1511
1558
  renderIdentity(views, lines);
1512
- renderStages(views, lines);
1559
+ renderStages(views, lines, inputs);
1513
1560
  renderContext(views, lines);
1514
1561
  renderDoctor(views, lines);
1515
1562
  renderVerdict(views, lines);
@@ -1806,7 +1853,7 @@ export function resolvePaths(root, overrides = {}) {
1806
1853
  export function projectTarget(root, overrides = {}) {
1807
1854
  const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
1808
1855
  const views = loadArtifacts(paths, { packet: packetView });
1809
- return { views, staleness: assessStaleness(root, views), packetSelection };
1856
+ return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
1810
1857
  }
1811
1858
 
1812
1859
  // The bundled synthetic sample `--example` projects. It is already on the
@@ -1837,6 +1884,7 @@ export const EXAMPLE_HEAD_DETAIL =
1837
1884
  export function projectExample() {
1838
1885
  const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
1839
1886
  const views = loadArtifacts(paths, { packet: packetView });
1887
+ const inputs = readbackInputs(views);
1840
1888
  for (const view of Object.values(views)) {
1841
1889
  const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
1842
1890
  view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
@@ -1844,7 +1892,7 @@ export function projectExample() {
1844
1892
  const staleness = assessStaleness(EXAMPLE_ROOT, views, {
1845
1893
  headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
1846
1894
  });
1847
- return { views, staleness, packetSelection };
1895
+ return { views, staleness, packetSelection, inputs };
1848
1896
  }
1849
1897
 
1850
1898
  const OVERRIDE_FLAGS = {
@@ -1938,9 +1986,9 @@ export function runReadbackCommand(args) {
1938
1986
  if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
1939
1987
  throw error;
1940
1988
  }
1941
- const { views, staleness, packetSelection } = projection;
1989
+ const { views, staleness, packetSelection, inputs } = projection;
1942
1990
  const text = request.json
1943
1991
  ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
1944
- : projectReadback(views, staleness, packetSelection);
1992
+ : projectReadback(views, staleness, packetSelection, inputs);
1945
1993
  return { exitCode: 0, text };
1946
1994
  }