@nextcommerce/campaigns-os 1.34.1 → 1.37.1

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 (115) hide show
  1. package/AGENTS.md +42 -13
  2. package/CHANGELOG.md +87 -0
  3. package/README.md +39 -20
  4. package/contracts/agent-relevant-change-policy.v1.json +1 -0
  5. package/contracts/fixtures/progress/observation.v0.json +89 -0
  6. package/contracts/release-ledger.json +359 -0
  7. package/contracts/supported-surface.json +20 -5
  8. package/demo/apollo-v0/NOTICE.txt +51 -0
  9. package/demo/apollo-v0/assets/css/demo.css +2 -0
  10. package/demo/apollo-v0/assets/css/landing/tokens.css +35 -0
  11. package/demo/apollo-v0/assets/css/next-core.css +16124 -0
  12. package/demo/apollo-v0/assets/images/1x1_1.svg +19 -0
  13. package/demo/apollo-v0/assets/images/1x1_2.svg +19 -0
  14. package/demo/apollo-v0/assets/images/affirm-logo.svg +24 -0
  15. package/demo/apollo-v0/assets/images/apple-pay-logo.svg +4 -0
  16. package/demo/apollo-v0/assets/images/bancontact-logo.svg +1 -0
  17. package/demo/apollo-v0/assets/images/cc-visa.svg +28 -0
  18. package/demo/apollo-v0/assets/images/cc_amex.svg +20 -0
  19. package/demo/apollo-v0/assets/images/cc_discover.svg +21 -0
  20. package/demo/apollo-v0/assets/images/cc_master.svg +22 -0
  21. package/demo/apollo-v0/assets/images/credit-card-flags.svg +38 -0
  22. package/demo/apollo-v0/assets/images/demo-inline-40788e52a7c75b78.svg +1 -0
  23. package/demo/apollo-v0/assets/images/demo-inline-686e73c8840a0f0a.svg +1 -0
  24. package/demo/apollo-v0/assets/images/google-pay-logo.svg +7 -0
  25. package/demo/apollo-v0/assets/images/guarantee-badge.png +0 -0
  26. package/demo/apollo-v0/assets/images/icon-dollar.svg +5 -0
  27. package/demo/apollo-v0/assets/images/icon-guarantee.svg +5 -0
  28. package/demo/apollo-v0/assets/images/icon-shipping.svg +5 -0
  29. package/demo/apollo-v0/assets/images/icons8-lock-24_1icons8-lock-24.png +0 -0
  30. package/demo/apollo-v0/assets/images/ideal-logo.svg +30 -0
  31. package/demo/apollo-v0/assets/images/klarna-logo.svg +9 -0
  32. package/demo/apollo-v0/assets/images/landing/_shared/16x9.svg +19 -0
  33. package/demo/apollo-v0/assets/images/landing/_shared/1x1_1.svg +19 -0
  34. package/demo/apollo-v0/assets/images/landing/_shared/4x3.svg +19 -0
  35. package/demo/apollo-v0/assets/images/landing/_shared/arrow-right.svg +6 -0
  36. package/demo/apollo-v0/assets/images/landing/_shared/cta-guarantee-icon.png +0 -0
  37. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-1.svg +8 -0
  38. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-2.svg +10 -0
  39. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-3.svg +13 -0
  40. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-icon-4.svg +11 -0
  41. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-quote.svg +6 -0
  42. package/demo/apollo-v0/assets/images/landing/benefits-2/benefits-2-verified.svg +10 -0
  43. package/demo/apollo-v0/assets/images/landing/bottomcta-1/check-bullet.svg +5 -0
  44. package/demo/apollo-v0/assets/images/landing/faq-1/faq-chevron.svg +6 -0
  45. package/demo/apollo-v0/assets/images/landing/footer-1/footer-logo.png +0 -0
  46. package/demo/apollo-v0/assets/images/landing/guarantee-1/guarantee-1-badge.svg +8 -0
  47. package/demo/apollo-v0/assets/images/landing/hero-1/icon-check.svg +5 -0
  48. package/demo/apollo-v0/assets/images/landing/hero-1/icon-star.svg +6 -0
  49. package/demo/apollo-v0/assets/images/landing/hero-1/icon-verified.svg +6 -0
  50. package/demo/apollo-v0/assets/images/landing/icons-5/icon-batteries.svg +7 -0
  51. package/demo/apollo-v0/assets/images/landing/icons-5/icon-cuff-checking.svg +6 -0
  52. package/demo/apollo-v0/assets/images/landing/icons-5/icon-dual-user.svg +5 -0
  53. package/demo/apollo-v0/assets/images/landing/icons-5/icon-fda-cleared.svg +5 -0
  54. package/demo/apollo-v0/assets/images/landing/icons-5/icon-heartbeat.svg +17 -0
  55. package/demo/apollo-v0/assets/images/landing/icons-5/icon-lcd-display.svg +10 -0
  56. package/demo/apollo-v0/assets/images/landing/icons-5/icon-movement-error.svg +7 -0
  57. package/demo/apollo-v0/assets/images/landing/icons-5/icon-portable.png +0 -0
  58. package/demo/apollo-v0/assets/images/landing/icons-5/icon-reading-memory.svg +6 -0
  59. package/demo/apollo-v0/assets/images/landing/icons-5/icon-wrist-comfort.svg +5 -0
  60. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-1.jpg +0 -0
  61. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-2.jpg +0 -0
  62. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-3.jpg +0 -0
  63. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-card-4.jpg +0 -0
  64. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-1.png +0 -0
  65. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-2.png +0 -0
  66. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-3.png +0 -0
  67. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-4.png +0 -0
  68. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-5.png +0 -0
  69. package/demo/apollo-v0/assets/images/landing/ingredients-3/ingredients-3-promise-6.png +0 -0
  70. package/demo/apollo-v0/assets/images/landing/nav-1/flag-us.png +0 -0
  71. package/demo/apollo-v0/assets/images/landing/reviews-3/star-card.svg +3 -0
  72. package/demo/apollo-v0/assets/images/landing/reviews-3/star-lg.svg +3 -0
  73. package/demo/apollo-v0/assets/images/landing/reviews-3/star-sm.svg +3 -0
  74. package/demo/apollo-v0/assets/images/landing/reviews-3/verified.svg +10 -0
  75. package/demo/apollo-v0/assets/images/landing/testimonials-2/testimonials-2-reactions.svg +9 -0
  76. package/demo/apollo-v0/assets/images/landing/testimonials-2/testimonials-2-stars.svg +6 -0
  77. package/demo/apollo-v0/assets/images/link-logo.svg +1 -0
  78. package/demo/apollo-v0/assets/images/next-dark.svg +8 -0
  79. package/demo/apollo-v0/assets/images/paypal-logo.svg +5 -0
  80. package/demo/apollo-v0/assets/images/paypal-txt.svg +8 -0
  81. package/demo/apollo-v0/assets/images/paypal.svg +22 -0
  82. package/demo/apollo-v0/assets/images/sepa-logo.svg +275 -0
  83. package/demo/apollo-v0/assets/images/twint-logo.svg +1 -0
  84. package/demo/apollo-v0/assets/images/united-states-flag-icon.webp +0 -0
  85. package/demo/apollo-v0/assets/images/upsell-payment-logos.svg +38 -0
  86. package/demo/apollo-v0/assets/images/usps.png +0 -0
  87. package/demo/apollo-v0/checkout/index.html +1370 -0
  88. package/demo/apollo-v0/landing/index.html +1836 -0
  89. package/demo/apollo-v0/provenance.json +342 -0
  90. package/demo/apollo-v0/receipt/index.html +292 -0
  91. package/demo/apollo-v0/upsell-bundle-stepper/index.html +401 -0
  92. package/docs/activation-and-evidence.md +37 -0
  93. package/docs/demo-preview.md +63 -0
  94. package/docs/diagnostics.md +55 -0
  95. package/docs/orientation-contract-reference.md +2 -1
  96. package/docs/progress-snapshots.md +142 -0
  97. package/docs/runtime-readiness.md +1 -1
  98. package/docs/supported-surface.md +13 -1
  99. package/package.json +13 -3
  100. package/schemas/campaign-runtime-build-context.v0.schema.json +42 -0
  101. package/schemas/campaigns-os-progress-snapshot.v0.schema.json +370 -0
  102. package/skills/next-campaigns-build/SKILL.md +17 -1
  103. package/skills/next-campaigns-os/SKILL.md +19 -3
  104. package/skills/next-campaigns-os-setup/SKILL.md +17 -1
  105. package/skills/next-campaigns-polish/SKILL.md +18 -2
  106. package/skills/next-campaigns-qa/SKILL.md +24 -8
  107. package/skills.json +5 -5
  108. package/src/cli.mjs +74 -4
  109. package/src/consent.mjs +2 -2
  110. package/src/demo-artifact.mjs +97 -0
  111. package/src/demo.mjs +85 -0
  112. package/src/diagnostic.mjs +101 -0
  113. package/src/install-mode.mjs +13 -1
  114. package/src/progress-node.mjs +177 -0
  115. package/src/progress.mjs +133 -0
@@ -0,0 +1,101 @@
1
+ // A projection, never a scrubber: source objects and free text never enter
2
+ // the export. Unknown producer values have fixed, non-actionable markers.
3
+ const MODES = new Set(["checkout", "node_modules", "global", "npx_cache", "package_directory"]);
4
+ const PLATFORMS = new Set(["claude", "codex", "agents", "all"]);
5
+ const STAGES = new Set(["prepare-build", "doctor-blocked", "setup", "build", "polish", "deploy", "qa", "done"]);
6
+ const STATUSES = new Set(["ready", "attention_required", "ready_with_warnings", "ready_with_waivers", "blocked"]);
7
+ const REASONS = new Set([
8
+ "assembly.template_lock", "campaign.allowed_domains_confirmed", "deploy.preview_url",
9
+ "page_kit.scaffold_required", "spec.page_url_html_extension", "spec.route_collision",
10
+ "scope.runtime_qa_blocked", "scope.partial_build", "content_residue.needs_merchant_input",
11
+ "page_kit.store_profile.mismatch", "page_kit.store_profile.demo_residue",
12
+ "page_kit.sdk_version.expected_observed_mismatch", "page_kit.sdk_version.repo_newer",
13
+ "page_kit.sdk_version.conflicting_declarations", "polish.evidence_missing",
14
+ "polish.assembly_source_package_stale", "polish.source_package_stale", "polish.stale",
15
+ "polish.hidden_eager_media.capture_missing", "polish.hidden_eager_media.capture_malformed",
16
+ "theme_gate.starter_palette_only", "built_output.campaign_identity",
17
+ "built_output.upsell_selector_scope", "built_output.sdk_markup.swap_with_add_to_cart",
18
+ "built_output.sdk_markup.checkout_not_form", "built_output.sdk_markup.wrong_field_name",
19
+ "built_output.sdk_markup.missing_selector_id_match",
20
+ ]);
21
+ const ACTIONS = new Set([
22
+ "repair_target", "align_store_profile", "align_sdk_version", "repair_waiver", "waive_checkpoint",
23
+ "rerun_build", "run_polish", "theme_generate", "apply_brand_layer", "waive_theme",
24
+ "polish.hidden_eager_media.capture", "polish.hidden_eager_media.install_browser",
25
+ "polish.hidden_eager_media.repair", "polish.hidden_eager_media.repair_authority",
26
+ "polish.hidden_eager_media.local_proof_rebuild", "polish.hidden_eager_media.waive",
27
+ ]);
28
+
29
+ const RECOVERY = Object.freeze({
30
+ skills: { owner: "operator", action_id: "install-skills", input_needed: "Selected agent profile", instruction: "Refresh the bundled skills for that profile and restart the agent." },
31
+ pin: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed toolkit version and lockfile", instruction: "Compare the installed package with the reviewed version; registry currency is not checked." },
32
+ update: { owner: "operator", action_id: "tooling.status", input_needed: "Reviewed upstream revision", instruction: "Review and update the toolkit checkout using the existing worktree workflow." },
33
+ doctor: { owner: "workflow_owner", action_id: "doctor", input_needed: "Local packet inputs and detailed doctor findings", instruction: "Inspect doctor locally and follow its existing owner, required inputs, and recovery actions." },
34
+ unsupported: { owner: "toolkit_maintainer", action_id: "tooling.diagnose", input_needed: "Producer vocabulary supported by this diagnostic version", instruction: "Review unsupported producer values locally; this export gives them no authority." },
35
+ });
36
+
37
+ export function diagnosticExport({ tooling = null, doctor = null, platform = "all", inspectionFailed = false } = {}) {
38
+ const reasonIds = new Set();
39
+ const actionIds = new Set();
40
+ const recovery = [];
41
+ const enumValue = (value, allowed) => {
42
+ if (allowed.has(value)) return value;
43
+ reasonIds.add("diagnostic.unsupported_value");
44
+ return "unknown";
45
+ };
46
+ const safePlatform = enumValue(platform, PLATFORMS);
47
+ const mode = tooling ? enumValue(tooling.install?.mode, MODES) : "unknown";
48
+ const version = typeof tooling?.package?.version === "string" && /^\d{1,6}\.\d{1,6}\.\d{1,6}$/.test(tooling.package.version)
49
+ ? tooling.package.version : null;
50
+ const toolingStatus = tooling ? enumValue(tooling.status, STATUSES) : "unavailable";
51
+ if (!tooling) reasonIds.add("diagnostic.tooling_unavailable");
52
+ const doctorStatus = doctor ? enumValue(doctor.status, STATUSES) : inspectionFailed ? "unavailable" : "not_requested";
53
+ const stage = doctor ? enumValue(doctor.next?.stage, STAGES) : "unknown";
54
+ let freshness = "unknown";
55
+ if (tooling?.install?.pinned?.commit && /^[0-9a-f]{7,40}$/.test(tooling.install.pinned.commit)) freshness = "pinned";
56
+ else if (mode === "checkout" && tooling?.git?.status === "ok" && Number.isInteger(tooling.git.behind) && tooling.git.behind >= 0) {
57
+ freshness = tooling.git.behind > 0 ? "local_ref_behind" : "local_ref_current";
58
+ }
59
+ if (tooling) {
60
+ if (typeof tooling.skills?.ok !== "boolean") {
61
+ reasonIds.add("diagnostic.unsupported_value");
62
+ } else if (tooling.skills.ok === false) {
63
+ reasonIds.add("tooling.skills_stale"); actionIds.add("install-skills"); recovery.push(RECOVERY.skills);
64
+ }
65
+ }
66
+ if (freshness === "unknown") { reasonIds.add("tooling.freshness_unknown"); recovery.push(RECOVERY.pin); }
67
+ if (freshness === "local_ref_behind") { reasonIds.add("tooling.checkout_behind"); recovery.push(RECOVERY.update); }
68
+ for (const issue of [...(Array.isArray(doctor?.errors) ? doctor.errors : []), ...(Array.isArray(doctor?.warnings) ? doctor.warnings : [])]) {
69
+ reasonIds.add(REASONS.has(issue?.code) ? issue.code : "diagnostic.unsupported_reason");
70
+ }
71
+ for (const gate of Array.isArray(doctor?.derived?.checkpoint_gates) ? doctor.derived.checkpoint_gates : []) {
72
+ for (const action of Array.isArray(gate?.required_actions) ? gate.required_actions : []) {
73
+ actionIds.add(ACTIONS.has(action?.id) ? action.id : "diagnostic.unsupported_action");
74
+ }
75
+ }
76
+ if ((doctor && doctorStatus !== "ready") || inspectionFailed) {
77
+ if (inspectionFailed) reasonIds.add("diagnostic.inspection_unavailable");
78
+ actionIds.add("doctor"); recovery.push(RECOVERY.doctor);
79
+ }
80
+ if ([...reasonIds, ...actionIds].some((id) => id.startsWith("diagnostic.unsupported_"))) recovery.push(RECOVERY.unsupported);
81
+ return {
82
+ schema_version: "campaigns-os-diagnostic/v0",
83
+ version, install_mode: mode, platform: safePlatform,
84
+ tooling_status: toolingStatus, doctor_status: doctorStatus, stage,
85
+ freshness: { toolkit: freshness, inspection: doctor ? "observed" : inspectionFailed ? "unavailable" : "not_requested" },
86
+ reason_ids: [...reasonIds].sort(), action_ids: [...actionIds].sort(), recovery,
87
+ };
88
+ }
89
+
90
+ export function diagnosticTextLines(result) {
91
+ return [
92
+ "Campaigns OS diagnostic (redacted)",
93
+ `Version: ${result.version || "unknown"}; install: ${result.install_mode}; platform: ${result.platform}`,
94
+ `Tooling: ${result.tooling_status}; doctor: ${result.doctor_status}; next stage: ${result.stage}`,
95
+ `Freshness: ${result.freshness.toolkit}; local inspection: ${result.freshness.inspection}`,
96
+ `Reasons: ${result.reason_ids.join(", ") || "none"}`,
97
+ `Actions: ${result.action_ids.join(", ") || "none"}`,
98
+ ...result.recovery.map((item) => `Recovery (${item.owner}, ${item.action_id}): ${item.instruction} Input needed: ${item.input_needed}.`),
99
+ "This summary contains no campaign content or paths. Inspect detailed recovery locally. QA publication recovery uses the retained verdict with qa publish; never repeat an order to publish evidence.",
100
+ ];
101
+ }
@@ -5,12 +5,14 @@
5
5
  import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync } from "node:fs";
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { basename, delimiter, dirname, join, relative, resolve, sep } from "node:path";
8
+ import { shellToken } from "./shell-token.mjs";
8
9
 
9
10
  const PACKAGE_INSTALL_MODE_LABELS = Object.freeze({
10
11
  checkout: "git checkout",
11
12
  npx_cache: "package install (npx cache)",
12
13
  node_modules: "package install (node_modules)",
13
14
  package_directory: "package install",
15
+ global: "global package install",
14
16
  });
15
17
 
16
18
  const PUBLIC_GIT_SOURCE = "github:NextCommerceCo/campaigns-os";
@@ -111,9 +113,18 @@ export function localInstallStatus(root, pkg = {}) {
111
113
  const segments = root.split(sep);
112
114
  const npxIndex = segments.indexOf("_npx");
113
115
  const inNpxCache = npxIndex >= 0 && /^[0-9a-f]{8,}$/i.test(segments[npxIndex + 1] || "") && segments[npxIndex + 2] === "node_modules";
116
+ const modules = enclosingNodeModules(root);
117
+ // npm's global layout is prefix/lib/node_modules on POSIX and
118
+ // prefix/node_modules on Windows. Verify the prefix's own bin points to
119
+ // this package, independently of PATH, which may select another install.
120
+ const globalPrefix = modules && (process.platform === "win32" ? dirname(modules) : basename(dirname(modules)) === "lib" ? dirname(dirname(modules)) : null);
121
+ const globalBin = globalPrefix ? join(globalPrefix, process.platform === "win32" ? "campaigns-os.cmd" : "bin/campaigns-os") : null;
122
+ const ownBin = resolve(root, typeof pkg.bin === "object" ? pkg.bin["campaigns-os"] || "bin/campaigns-os.mjs" : "bin/campaigns-os.mjs");
123
+ const globalInstall = globalBin && existsSync(globalBin) && executableTargetPath(globalBin) === realpathOrSelf(ownBin);
114
124
  const mode = inNpxCache
115
125
  ? "npx_cache"
116
- : enclosingNodeModules(root)
126
+ : globalInstall ? "global"
127
+ : modules
117
128
  ? "node_modules"
118
129
  : "package_directory";
119
130
  const pinned = derivePackagePin(root, pkg);
@@ -227,6 +238,7 @@ export function resolveInvocation(root, pkg = {}, install = localInstallStatus(r
227
238
  if (install.mode === "checkout") prefix = "npm run campaigns-os --";
228
239
  else if (install.mode === "npx_cache") prefix = install.pinned?.spec ? `npx --yes ${install.pinned.spec}` : "campaigns-os";
229
240
  else if (install.mode === "node_modules") prefix = "npx campaigns-os";
241
+ else if (install.mode === "global") prefix = matches ? "campaigns-os" : `node ${shellToken(localBin)}`;
230
242
  else prefix = "campaigns-os";
231
243
  return {
232
244
  install,
@@ -0,0 +1,177 @@
1
+ // Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
2
+ import {randomBytes,createHash} from 'node:crypto';
3
+ import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
4
+ import {join,resolve,dirname} from 'node:path';
5
+ import {PROGRESS_SCHEMA_VERSION,PROGRESS_STAGES,PROGRESS_STAGE_STATUSES,PROGRESS_CONTINUATIONS,PROGRESS_ACTION_IDS,PROGRESS_GATE_IDS,canonicalProgressJson,progressSnapshotId,verifyProgressSnapshot} from './progress.mjs';
6
+ import {specMaterialHash} from './spec-identity.mjs';
7
+ import {sameFile} from './fs-identity.mjs';
8
+ import {resolveConsent,CANONICAL_REMIT_SCOPE,normalizeConsentScope,announceDefaultOnTelemetry} from './consent.mjs';
9
+ import {boundedResponseText,isLoopbackHostname} from './remit.mjs';
10
+ export const PROGRESS_OBSERVATION = Symbol('canonical progress observation');
11
+ export const PROGRESS_ENDPOINT = '/api/progress';
12
+ const accepted = (value,values,fallback='unknown')=>values.includes(value)?value:fallback;
13
+ const hash = value=>typeof value==='string'&&/^(?:sha256:)?[0-9a-f]{64}$/i.test(value)?`sha256:${value.replace(/^sha256:/i,'').toLowerCase()}`:null;
14
+ const id = value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(value)?value:null;
15
+ const digest = value=>`sha256:${createHash('sha256').update(value).digest('hex')}`;
16
+ const read = path=>{try{return JSON.parse(readFileSync(path,'utf8'));}catch{return null;}};
17
+ function atomic(path,value) {const tmp=`${path}.${randomBytes(8).toString('hex')}.tmp`;writeFileSync(tmp,`${canonicalProgressJson(value)}\n`,{mode:0o600});renameSync(tmp,path);}
18
+ export function resolveProgressEndpoint(args,workspace,context) {
19
+ // A foreign context cannot donate a source endpoint or quietly fall back.
20
+ const named=context?.packet_path;
21
+ const bound=typeof named==='string'&&named.trim()&&sameFile(resolve(workspace.targetRepo,named),workspace.packetPath,{requireExisting:true});
22
+ const explicit=args['proxy-base'];
23
+ const source=context?.intake?.proxy_base;
24
+ if (!explicit && !bound && source) return {ok:false,reason:'source_binding_invalid'};
25
+ if (explicit===undefined && (source===null||source===undefined) && workspace.packet?.spec?.spec_url) {
26
+ try { if (new URL(workspace.packet.spec.spec_url).origin!==CANONICAL_REMIT_SCOPE) return {ok:false,reason:'source_binding_missing'}; } catch { return {ok:false,reason:'source_binding_invalid'}; }
27
+ }
28
+ const raw=explicit!==undefined?explicit:source!==null&&source!==undefined?source:CANONICAL_REMIT_SCOPE;
29
+ if (typeof raw!=='string'||!raw.trim()) return {ok:false,reason:'endpoint_invalid'};
30
+ try {
31
+ const url=new URL(raw);
32
+ if (url.username||url.password||url.search||url.hash||!['https:','http:'].includes(url.protocol)||(url.protocol==='http:'&&!isLoopbackHostname(url.hostname))) return {ok:false,reason:'endpoint_invalid'};
33
+ const base=url.href.replace(/\/+$/,'');
34
+ return {ok:true,base,scope:normalizeConsentScope(base)};
35
+ } catch {return {ok:false,reason:'endpoint_invalid'};}
36
+ }
37
+ export function projectProgressObservation({workspace,context,report,doctor,continuation,qaResult=null,packageVersion}) {
38
+ const packet=workspace.packet;
39
+ let spec=null;
40
+ try {spec=JSON.parse(readFileSync(resolve(dirname(workspace.packetPath),packet.spec.local_path),'utf8'));}catch{}
41
+ const mapId=id(packet?.spec?.map_id);
42
+ const localHash=spec?hash(specMaterialHash(spec)):null;
43
+ const localMapId=id(spec?.spec_identity?.map_id||spec?.map_id);
44
+ const baseline=context?.intake?.saved_map_revision;
45
+ const contextBound=typeof context?.packet_path==='string'&&context.packet_path.trim()&&sameFile(resolve(workspace.targetRepo,context.packet_path),workspace.packetPath,{requireExisting:true});
46
+ const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
47
+ const build=hash(doctor?.derived?.build_output_fingerprint?.value);
48
+ const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
49
+ const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&mapId&&localMapId===mapId&&report?.identity?.map_id===mapId&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
50
+ const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
51
+ const verdict=qaResult?.verdict;
52
+ const qa=verdict?{
53
+ verdict_id:id(verdict.run_id),disposition:accepted(verdict.disposition,['ready','ready_with_exceptions','blocked']),
54
+ binding:reportBound&&id(verdict.run_id)&&id(verdict.run_id)===id(report?.stages?.qa?.verdict_run_id)&&build&&qaSource===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'&&hash(verdict.spec_hash)===localHash?'matching':'unconfirmed',
55
+ publish_state:accepted(qaResult?.qa_verdict_publish?.state,['skipped','ok','failed']),
56
+ }:null;
57
+ const gates=(Array.isArray(continuation?.gates)?continuation.gates:[]).slice(0,16).map(gate=>({id:accepted(gate?.id,PROGRESS_GATE_IDS),state:PROGRESS_GATE_IDS.includes(gate?.id)?accepted(gate?.status,['pass','blocked','waived','not_applicable']):'unknown'}));
58
+ const actions=[...new Set((Array.isArray(continuation?.next_actions)?continuation.next_actions:[]).slice(0,64).map(action=>accepted(action?.id,PROGRESS_ACTION_IDS)))];
59
+ const stage=accepted(continuation?.stage,PROGRESS_CONTINUATIONS);
60
+ const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
61
+ return {
62
+ schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
63
+ identity:{map_id:mapId,map_revision_hash:contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null),map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
64
+ stages:PROGRESS_STAGES.map(stage=>{
65
+ const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
66
+ const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
67
+ return {stage,status,source_build_fingerprint:source,build_binding:reportBound&&build&&source===build&&doctor?.derived?.build_output_fingerprint?.status==='pass'?'matching':'unconfirmed'};
68
+ }),
69
+ preview:{present:Boolean(preview),url_hash:preview?digest(preview):null},
70
+ continuation:{stage,blocked:continuation?.ok!==true||(Array.isArray(continuation?.divergences)&&continuation.divergences.length>0)||stage==='unknown'||gates.some(gate=>['blocked','unknown'].includes(gate.state))||actions.includes('unknown'),divergent:Array.isArray(continuation?.divergences)&&continuation.divergences.length>0,action_ids:actions,gates},qa,
71
+ };
72
+ }
73
+ async function lock(dir,fn,{budgetMs=1500}={}) {
74
+ const path=join(dir,'.allocation-lock'); const start=Date.now();const token=randomBytes(16).toString('hex');
75
+ const abandoned=(unownedMtime=null)=>{
76
+ try {
77
+ const stat=lstatSync(path);if(!stat.isDirectory()||stat.isSymbolicLink())return false;
78
+ const owner=read(join(path,'owner.json'));
79
+ if(Number.isInteger(owner?.pid)&&owner.pid>0&&typeof owner.token==='string') {
80
+ try {process.kill(owner.pid,0);return false;}catch(error){return error.code==='ESRCH';}
81
+ }
82
+ // A killed process can leave the directory before writing its owner.
83
+ // Give a live allocator ample time to finish that tiny synchronous gap.
84
+ return Date.now()-(unownedMtime??stat.mtimeMs)>10000;
85
+ }catch{return false;}
86
+ };
87
+ const recover=()=>{
88
+ if(!abandoned())return;
89
+ let originalMtime;try{originalMtime=lstatSync(path).mtimeMs;}catch{return;}
90
+ const claim=join(path,'.recovery');
91
+ // Only this exclusive claimant can rename the old lock. An interrupted
92
+ // recovery claim fails closed for explicit offline recovery; recursively
93
+ // stealing recovery claims would reintroduce a check/rename race.
94
+ try {mkdirSync(claim);atomic(join(claim,'owner.json'),{pid:process.pid,token});}catch{return;}
95
+ let moved=false;
96
+ try {
97
+ if(!abandoned(originalMtime))return;
98
+ const tomb=`${path}.abandoned-${token}`;
99
+ renameSync(path,tomb);moved=true;rmSync(tomb,{recursive:true,force:true});
100
+ }catch{}finally{if(!moved&&read(join(claim,'owner.json'))?.token===token){try{rmSync(claim,{recursive:true,force:true});}catch{}}}
101
+ };
102
+ while (true) {
103
+ try {mkdirSync(path);atomic(join(path,'owner.json'),{pid:process.pid,token});break;}catch(error){if(error.code!=='EEXIST'||Date.now()-start>=budgetMs)throw new Error('progress.lock_unavailable');recover();await new Promise(resolve=>setTimeout(resolve,20));}
104
+ }
105
+ try{return await fn();}finally{if(read(join(path,'owner.json'))?.token===token)rmSync(path,{recursive:true,force:true});}
106
+ }
107
+ export async function persistProgressObservation(observation,{dir,now=()=>new Date(),historyLimit=32}={}) {
108
+ mkdirSync(dir,{recursive:true,mode:0o700});
109
+ return lock(dir,async()=>{
110
+ const latest=read(join(dir,'latest.json'));
111
+ const latestValid=latest&&(await verifyProgressSnapshot(latest)).ok;
112
+ const observationHash=digest(canonicalProgressJson(observation));
113
+ if (latestValid) {
114
+ const {snapshot_id,stream_id,sequence,previous_snapshot_id,observed_at,...old}=latest;
115
+ if (digest(canonicalProgressJson(old))===observationHash) return {snapshot:latest,reused:true};
116
+ }
117
+ // A new spec/build identity starts an independent history; stage completion
118
+ // from the old output cannot become completion for the new output.
119
+ const sameIdentity=latestValid&&canonicalProgressJson(latest.identity)===canonicalProgressJson(observation.identity);
120
+ const snapshot={...observation,stream_id:sameIdentity?latest.stream_id:`progress_${randomBytes(16).toString('hex')}`,sequence:sameIdentity?latest.sequence+1:1,previous_snapshot_id:sameIdentity?latest.snapshot_id:null,observed_at:now().toISOString()};
121
+ snapshot.snapshot_id=await progressSnapshotId(snapshot);
122
+ if (!(await verifyProgressSnapshot(snapshot)).ok) throw new Error('progress.invalid_projection');
123
+ atomic(join(dir,`${snapshot.snapshot_id.slice(7)}.snapshot.json`),snapshot);
124
+ atomic(join(dir,'latest.json'),snapshot);
125
+ const history=readdirSync(dir).filter(name=>/^[0-9a-f]{64}\.snapshot\.json$/.test(name)).map(name=>({name,at:read(join(dir,name))?.observed_at||''})).sort((a,b)=>a.name===`${snapshot.snapshot_id.slice(7)}.snapshot.json`?-1:b.name===`${snapshot.snapshot_id.slice(7)}.snapshot.json`?1:b.at.localeCompare(a.at)||b.name.localeCompare(a.name));
126
+ const limit=Math.min(32,Math.max(1,historyLimit));
127
+ for (const old of history.slice(limit)) if(old.name!==`${snapshot.snapshot_id.slice(7)}.snapshot.json`) {rmSync(join(dir,old.name),{force:true});rmSync(join(dir,old.name.replace('.snapshot.json','.remit.json')),{force:true});}
128
+ return {snapshot,reused:false};
129
+ });
130
+ }
131
+ export async function remitProgressSnapshot(snapshot,{base,campaignKey,fetchImpl=globalThis.fetch,budgetMs=2000}={}) {
132
+ const bytes=canonicalProgressJson(snapshot);const deadline=Date.now()+Math.min(2000,Math.max(1,budgetMs));
133
+ let reason='transport_failed';let attempts=0;let httpStatus=null;
134
+ for (let attempt=0;attempt<2&&Date.now()<deadline;attempt++) {
135
+ attempts++;
136
+ try {
137
+ const response=await fetchImpl(`${base}${PROGRESS_ENDPOINT}`,{method:'POST',redirect:'error',headers:{'Content-Type':'application/json','Accept':'application/json','X-Campaign-Key':campaignKey},body:bytes,signal:AbortSignal.timeout(Math.max(1,deadline-Date.now()))});
138
+ httpStatus=response.status;
139
+ let ack=null;try{ack=JSON.parse(await boundedResponseText(response,{maxBodyBytes:4096,timeoutMs:Math.max(1,deadline-Date.now())}));}catch{}
140
+ if ((response.ok||response.status===409)&&ack?.ok===true&&ack.snapshot_id===snapshot.snapshot_id&&ack.digest===snapshot.snapshot_id) return {state:'ok',reason:'acknowledged',attempts,http_status:httpStatus};
141
+ reason=response.status===409?'conflict':response.ok?'ack_invalid':'http_refused';
142
+ if (!(response.status===429||response.status>=500)) break;
143
+ } catch {reason='transport_failed';}
144
+ }
145
+ return {state:'failed',reason,attempts,http_status:httpStatus};
146
+ }
147
+ export async function observeProgress(args,continuation,{qaResult=null,packageVersion,resolveKey,env=process.env,configPath,fetchImpl,warn=message=>process.stderr.write(`${message}\n`)}={}) {
148
+ if (args['no-write']===true) return {state:'disabled',reason:'no_write'};
149
+ try {
150
+ const source=continuation?.[PROGRESS_OBSERVATION];
151
+ if (!source) return {state:'skipped',reason:'observation_unavailable'};
152
+ const {workspace,context}=source;
153
+ const endpoint=resolveProgressEndpoint(args,workspace,context);
154
+ const observation=projectProgressObservation({...source,continuation,qaResult,packageVersion});
155
+ const storageScope=digest(canonicalProgressJson({packet:workspace.packetPath,context:workspace.contextPath,report:workspace.reportPath,endpoint:endpoint.ok?endpoint.scope:null})).slice(7);
156
+ const dir=join(workspace.targetRepo,'.campaign-runtime','progress',storageScope);
157
+ const {snapshot,reused}=await persistProgressObservation(observation,{dir});
158
+ const metaPath=join(dir,`${snapshot.snapshot_id.slice(7)}.remit.json`);
159
+ const prior=read(metaPath);
160
+ if (prior?.state==='ok') return {state:'ok',reason:'already_acknowledged',snapshot_id:snapshot.snapshot_id,reused};
161
+ let reason=!snapshot.identity.map_id?'map_id_missing':!endpoint.ok?endpoint.reason:args['no-remit']===true?'no_remit':null;
162
+ const consent=reason?null:resolveConsent({env,...(configPath?{configPath}:{}),proxyBase:endpoint.base,warn:()=>{}});
163
+ if (!reason&&consent.state!=='on') reason='consent_off';
164
+ if (!reason&&consent.scope_bypassed) reason='scoped_consent_required';
165
+ const key=reason?null:resolveKey?.(workspace.packet,workspace.packetPath,env)?.key;
166
+ if (!reason&&!key) reason='campaign_key_missing';
167
+ if (!reason) announceDefaultOnTelemetry(`${endpoint.base}${PROGRESS_ENDPOINT}`);
168
+ const remit=reason?{state:'skipped',reason,attempts:0,http_status:null}:await remitProgressSnapshot(snapshot,{base:endpoint.base,campaignKey:key,fetchImpl});
169
+ atomic(metaPath,{...remit,snapshot_id:snapshot.snapshot_id});
170
+ if(remit.state==='failed')warn('[campaigns-os] Progress delivery pending; the local observation is retained. Lifecycle result is unchanged.');
171
+ return {...remit,snapshot_id:snapshot.snapshot_id,reused};
172
+ } catch(error) {
173
+ if(error?.message==='progress.lock_unavailable')warn('[campaigns-os] Progress allocation lock occupied; wait for the current writer. For abandoned or interrupted recovery, stop target writers and follow the offline lock recovery in docs/progress-snapshots.md. Lifecycle result is unchanged.');
174
+ else warn('[campaigns-os] Progress observation unavailable; lifecycle result is unchanged.');
175
+ return {state:'failed',reason:'capture_unavailable'};
176
+ }
177
+ }
@@ -0,0 +1,133 @@
1
+ // Portable wire contract. No filesystem, network, Node globals or lifecycle picker.
2
+ export const PROGRESS_SCHEMA_VERSION = 'campaigns-os-progress-snapshot/v0';
3
+ export const PROGRESS_STAGES = ['prepare_build', 'setup', 'assembly', 'polish', 'deploy', 'qa'];
4
+ export const PROGRESS_CONTINUATIONS = ['prepare-build', 'doctor-blocked', 'setup', 'build', 'polish', 'deploy', 'qa', 'done', 'unknown'];
5
+ export const PROGRESS_STAGE_STATUSES = ['pending', 'required', 'blocked', 'completed', 'completed_with_warnings', 'skipped', 'unknown'];
6
+ export const PROGRESS_GATE_IDS = ['doctor', 'prepare_build', 'theme_gate', 'polish_gate', 'page_kit.sdk_version', 'page_kit.store_profile', 'built_output.upsell_selector_scope', 'polish.hidden_eager_media', 'unknown'];
7
+ export const PROGRESS_ACTION_IDS = [
8
+ 'doctor_recheck', 'restore_prepare_build_binding', 'recheck', 'rerun_prepare_build',
9
+ 'divergence_inspect', 'setup_skill', 'build_skill', 'build_local_proof', 'build_production_parity',
10
+ 'polish_skill', 'deploy', 'advance', 'install_browser', 'qa_run', 'run_end', 'run_record_present',
11
+ 'run_record_remit_recovery', 'run_record_closeout', 'purchase_proof_unknown',
12
+ 'theme_gate.starter_palette_blocks_qa', 'theme_gate.brand_contract_unreadable',
13
+ 'theme_gate.theme_generate', 'theme_gate.apply_brand_layer', 'theme_gate.waive_theme', 'theme_gate.fix_load_order',
14
+ 'polish_gate.rerun_build', 'polish_gate.run_polish', 'polish_gate.repair_waiver',
15
+ 'checkpoint.page_kit.sdk_version.repair_spec', 'checkpoint.page_kit.sdk_version.refresh_spec',
16
+ 'checkpoint.page_kit.store_profile.repair_spec', 'checkpoint.built_output.upsell_selector_scope.repair_selectors',
17
+ ...['page_kit.sdk_version', 'page_kit.store_profile', 'built_output.upsell_selector_scope'].flatMap(id => ['repair_target', 'align_store_profile', 'align_sdk_version', 'repair_waiver', 'waive'].map(action => `checkpoint.${id}.${action}`)),
18
+ ...['capture', 'install_browser', 'repair', 'repair_authority', 'local_proof_rebuild', 'waive'].map(action => `checkpoint.polish.hidden_eager_media.${action}`),
19
+ 'unknown',
20
+ ];
21
+ const str = pattern => ({type:'string', pattern});
22
+ const hash = {type:['string','null'], pattern:'^sha256:[0-9a-f]{64}$'};
23
+ const opaque = {type:['string','null'], pattern:'^[A-Za-z0-9_-]{1,64}$'};
24
+ const enumeration = values => ({enum:values});
25
+ const object = properties => ({type:'object', additionalProperties:false, required:Object.keys(properties), properties});
26
+ export const PROGRESS_SNAPSHOT_SCHEMA = {
27
+ $schema:'https://json-schema.org/draft/2020-12/schema',
28
+ $id:'https://nextcommerce.com/schemas/campaigns-os-progress-snapshot.v0.schema.json',
29
+ title:'Campaigns OS minimal progress observation v0',
30
+ ...object({
31
+ schema_version:{const:PROGRESS_SCHEMA_VERSION}, snapshot_id:str('^sha256:[0-9a-f]{64}$'),
32
+ stream_id:str('^progress_[0-9a-f]{32}$'), sequence:{type:'integer',minimum:1,maximum:2147483647},
33
+ previous_snapshot_id:hash, observed_at:str('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'),
34
+ package_version:str('^\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}$'), producer:enumeration(['next','qa']),
35
+ identity:object({map_id:opaque,map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
36
+ saved_revision_alignment:enumeration(['aligned','unconfirmed']),local_spec_material_hash:hash,
37
+ local_spec_material_algorithm:{const:'campaign-spec-material-v1'},build_fingerprint:hash,
38
+ build_fingerprint_algorithm:{const:'sha256-manifest/v1'}}),
39
+ stages:{type:'array',minItems:6,maxItems:6,items:object({stage:enumeration(PROGRESS_STAGES),status:enumeration(PROGRESS_STAGE_STATUSES),
40
+ build_binding:enumeration(['matching','unconfirmed']),source_build_fingerprint:hash})},
41
+ preview:object({present:{type:'boolean'},url_hash:hash}),
42
+ continuation:object({stage:enumeration(PROGRESS_CONTINUATIONS),blocked:{type:'boolean'},divergent:{type:'boolean'},
43
+ action_ids:{type:'array',maxItems:64,uniqueItems:true,items:enumeration(PROGRESS_ACTION_IDS)},
44
+ gates:{type:'array',maxItems:16,items:object({id:enumeration(PROGRESS_GATE_IDS),state:enumeration(['pass','blocked','waived','not_applicable','unknown'])})}}),
45
+ qa:{anyOf:[{type:'null'},object({verdict_id:opaque,disposition:enumeration(['ready','ready_with_exceptions','blocked','unknown']),
46
+ binding:enumeration(['matching','unconfirmed']),publish_state:enumeration(['skipped','ok','failed','unknown'])})]},
47
+ }),
48
+ };
49
+ export function canonicalProgressJson(value) {
50
+ if (Array.isArray(value)) return `[${value.map(canonicalProgressJson).join(',')}]`;
51
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonicalProgressJson(value[key])}`).join(',')}}`;
52
+ return JSON.stringify(value);
53
+ }
54
+ export async function progressDigest(value, cryptoImpl = globalThis.crypto) {
55
+ const bytes = new TextEncoder().encode(canonicalProgressJson(value));
56
+ const digest = await cryptoImpl.subtle.digest('SHA-256',bytes);
57
+ return `sha256:${Array.from(new Uint8Array(digest),byte=>byte.toString(16).padStart(2,'0')).join('')}`;
58
+ }
59
+ export async function progressSnapshotId(snapshot, cryptoImpl) {
60
+ const {snapshot_id:ignored,...content} = snapshot;
61
+ return progressDigest(content,cryptoImpl);
62
+ }
63
+ // Small bounded validator for exactly the schema vocabulary above. Fixed error codes,
64
+ // never values or property names from an untrusted payload.
65
+ export function validateProgressSnapshot(value) {
66
+ const errors = [];
67
+ let count = 0;
68
+ function check(data,schema,depth=0) {
69
+ if (++count>2048 || depth>12) return false;
70
+ if (schema.anyOf) return schema.anyOf.some(branch=>check(data,branch,depth+1));
71
+ if (Object.hasOwn(schema,'const') && data!==schema.const) return false;
72
+ if (schema.enum && !schema.enum.includes(data)) return false;
73
+ if (schema.type) {
74
+ const type = data===null?'null':Array.isArray(data)?'array':typeof data==='number'&&Number.isInteger(data)?'integer':typeof data;
75
+ const allowed = Array.isArray(schema.type)?schema.type:[schema.type];
76
+ if (!allowed.includes(type)) return false;
77
+ }
78
+ if (typeof data==='string' && schema.pattern && !new RegExp(schema.pattern).test(data)) return false;
79
+ if (typeof data==='number' && (data<schema.minimum||data>schema.maximum)) return false;
80
+ if (Array.isArray(data)) {
81
+ if (data.length<(schema.minItems||0)||data.length>(schema.maxItems||0)) return false;
82
+ if (Object.keys(data).length!==data.length) return false;
83
+ if (schema.uniqueItems && new Set(data).size!==data.length) return false;
84
+ return data.every(item=>check(item,schema.items,depth+1));
85
+ }
86
+ if (data && typeof data==='object') {
87
+ if (!schema.properties || Object.keys(data).some(key=>!Object.hasOwn(schema.properties,key))) return false;
88
+ if (schema.required.some(key=>!Object.hasOwn(data,key))) return false;
89
+ return Object.entries(data).every(([key,item])=>check(item,schema.properties[key],depth+1));
90
+ }
91
+ return true;
92
+ }
93
+ if (!check(value,PROGRESS_SNAPSHOT_SCHEMA)) errors.push('progress.invalid_shape');
94
+ if (!errors.length && (new Set(value.stages.map(stage=>stage.stage)).size!==6 || value.stages.some((stage,index)=>stage.stage!==PROGRESS_STAGES[index]))) errors.push('progress.invalid_stages');
95
+ if (!errors.length && value.identity.saved_revision_alignment==='aligned' && (!value.identity.map_id||!value.identity.map_revision_hash||!value.identity.local_spec_material_hash)) errors.push('progress.invalid_alignment');
96
+ if (!errors.length && value.continuation.gates.some(gate=>gate.id==='unknown'&&gate.state!=='unknown')) errors.push('progress.unsupported_authority');
97
+ if (!errors.length && value.sequence===1 && value.previous_snapshot_id!==null) errors.push('progress.invalid_chain');
98
+ if (!errors.length && value.sequence>1 && value.previous_snapshot_id===null) errors.push('progress.invalid_chain');
99
+ if (!errors.length && value.stages.some(stage=>stage.build_binding==='matching'&&(!value.identity.build_fingerprint||stage.source_build_fingerprint!==value.identity.build_fingerprint))) errors.push('progress.invalid_build_binding');
100
+ if (!errors.length && value.qa?.binding==='matching'&&(!value.qa.verdict_id||!value.identity.build_fingerprint||value.stages.find(stage=>stage.stage==='qa').build_binding!=='matching')) errors.push('progress.invalid_qa_binding');
101
+ if (!errors.length && (value.continuation.stage==='unknown'||value.continuation.action_ids.includes('unknown')||value.continuation.gates.some(gate=>gate.id==='unknown'||gate.state==='unknown'))&&!value.continuation.blocked) errors.push('progress.unsupported_authority');
102
+ if (!errors.length && value.preview.present!==(value.preview.url_hash!==null)) errors.push('progress.invalid_preview');
103
+ if (!errors.length && value.continuation.divergent&&!value.continuation.blocked) errors.push('progress.invalid_divergence');
104
+ if (!errors.length && !Number.isFinite(Date.parse(value.observed_at))) errors.push('progress.invalid_time');
105
+ return {ok:errors.length===0,errors};
106
+ }
107
+ export async function verifyProgressSnapshot(snapshot,cryptoImpl) {
108
+ const checked=validateProgressSnapshot(snapshot);
109
+ if (!checked.ok) return checked;
110
+ return (await progressSnapshotId(snapshot,cryptoImpl))===snapshot.snapshot_id?checked:{ok:false,errors:['progress.digest_mismatch']};
111
+ }
112
+ // Identity groups include build, local spec and saved revision; never merge stages
113
+ // across builds. Histories contain whole observations, not a readiness decision.
114
+ export async function groupProgressHistories(snapshots,{cryptoImpl,maxSnapshots=256}={}) {
115
+ if (!Array.isArray(snapshots)||snapshots.length>Math.min(maxSnapshots,256)) return {ok:false,groups:[],errors:['progress.history_bound']};
116
+ const groups=new Map(); let rejected=0;
117
+ for (const snapshot of snapshots) {
118
+ if (!(await verifyProgressSnapshot(snapshot,cryptoImpl)).ok) {rejected++;continue;}
119
+ const key=canonicalProgressJson({identity:snapshot.identity,stream_id:snapshot.stream_id});
120
+ if (!groups.has(key)) groups.set(key,[]);
121
+ groups.get(key).push(snapshot);
122
+ }
123
+ return {ok:rejected===0,errors:rejected?['progress.invalid_history_member']:[],groups:[...groups.values()].map(items=>{
124
+ const unique=[...new Map(items.map(item=>[item.snapshot_id,item])).values()].sort((a,b)=>a.sequence-b.sequence);
125
+ const conflicted=new Set(unique.map(item=>item.sequence)).size!==unique.length;
126
+ const incomplete=unique[0].sequence!==1 || unique.some((item,index)=>index>0&&(item.sequence!==unique[index-1].sequence+1||item.previous_snapshot_id!==unique[index-1].snapshot_id));
127
+ return {identity:unique[0].identity,stream_id:unique[0].stream_id,state:conflicted?'conflicted':incomplete?'incomplete':unique[0].identity.saved_revision_alignment!=='aligned'||!unique[0].identity.build_fingerprint?'unconfirmed':'observed',snapshots:unique};
128
+ })};
129
+ }
130
+ export function progressStorageKey(snapshot,scopeHash) {
131
+ if (!validateProgressSnapshot(snapshot).ok||!/^sha256:[0-9a-f]{64}$/.test(scopeHash)||!snapshot.identity.map_id||!snapshot.identity.map_revision_hash) return null;
132
+ return `progress:v0:${scopeHash.slice(7)}:${snapshot.identity.map_id}:${snapshot.identity.map_revision_hash.slice(7)}:${snapshot.stream_id}:${snapshot.sequence}:${snapshot.snapshot_id.slice(7)}`;
133
+ }